@kirrosh/zond 0.26.0 → 0.27.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 +59 -0
- package/README.md +22 -21
- package/bin/zond.mjs +23 -0
- package/package.json +13 -16
- package/scripts/npm/postinstall.mjs +76 -0
- package/src/CLAUDE.md +0 -112
- package/src/bun-types.d.ts +0 -5
- package/src/cli/argv.ts +0 -122
- package/src/cli/commands/add-api.ts +0 -146
- package/src/cli/commands/api/annotate/idempotency.ts +0 -59
- package/src/cli/commands/api/annotate/index.ts +0 -880
- package/src/cli/commands/api/annotate/lifecycle.ts +0 -74
- package/src/cli/commands/api/annotate/overlay.ts +0 -206
- package/src/cli/commands/api/annotate/pagination.ts +0 -64
- package/src/cli/commands/api/annotate/prompts.ts +0 -220
- package/src/cli/commands/api/annotate/readback.ts +0 -58
- package/src/cli/commands/api/annotate/resources.ts +0 -91
- package/src/cli/commands/api/annotate/seed-bodies.ts +0 -61
- package/src/cli/commands/audit.ts +0 -786
- package/src/cli/commands/catalog.ts +0 -97
- package/src/cli/commands/check.ts +0 -361
- package/src/cli/commands/checks.ts +0 -1072
- package/src/cli/commands/ci-init.ts +0 -223
- package/src/cli/commands/clean.ts +0 -212
- package/src/cli/commands/cleanup.ts +0 -236
- package/src/cli/commands/completions.ts +0 -192
- package/src/cli/commands/coverage.ts +0 -872
- package/src/cli/commands/db.ts +0 -611
- package/src/cli/commands/describe.ts +0 -120
- package/src/cli/commands/discover.ts +0 -1356
- package/src/cli/commands/doctor.ts +0 -661
- package/src/cli/commands/fixtures.ts +0 -402
- package/src/cli/commands/generate.ts +0 -577
- package/src/cli/commands/init/agents-md.ts +0 -61
- package/src/cli/commands/init/bootstrap.ts +0 -111
- package/src/cli/commands/init/index.ts +0 -244
- package/src/cli/commands/init/skills.ts +0 -141
- package/src/cli/commands/init/templates/agents.md +0 -86
- package/src/cli/commands/init/templates/markdown.d.ts +0 -4
- package/src/cli/commands/init/templates/skills/warm-up-target.md +0 -122
- package/src/cli/commands/init/templates/skills/zond-checks.md +0 -621
- package/src/cli/commands/init/templates/skills/zond-seed.md +0 -114
- package/src/cli/commands/init/templates/skills/zond-triage.md +0 -272
- package/src/cli/commands/init/templates/skills/zond.md +0 -861
- package/src/cli/commands/init/templates/zond-config.yml +0 -14
- package/src/cli/commands/prepare-fixtures.ts +0 -97
- package/src/cli/commands/probe/_seed-bodies.ts +0 -52
- package/src/cli/commands/probe/mass-assignment.ts +0 -594
- package/src/cli/commands/probe/security.ts +0 -537
- package/src/cli/commands/probe/static.ts +0 -255
- package/src/cli/commands/probe/webhooks.ts +0 -163
- package/src/cli/commands/probe.ts +0 -535
- package/src/cli/commands/reference.ts +0 -87
- package/src/cli/commands/refresh-api.ts +0 -227
- package/src/cli/commands/remove-api.ts +0 -150
- package/src/cli/commands/report-bundle.ts +0 -310
- package/src/cli/commands/report.ts +0 -241
- package/src/cli/commands/request.ts +0 -548
- package/src/cli/commands/run.ts +0 -1142
- package/src/cli/commands/schema-from-runs.ts +0 -128
- package/src/cli/commands/secrets.ts +0 -133
- package/src/cli/commands/session.ts +0 -244
- package/src/cli/commands/use.ts +0 -74
- package/src/cli/index.ts +0 -55
- package/src/cli/json-envelope.ts +0 -108
- package/src/cli/json-schemas.ts +0 -314
- package/src/cli/output.ts +0 -40
- package/src/cli/program.ts +0 -219
- package/src/cli/resolve.ts +0 -105
- package/src/cli/runtime.ts +0 -7
- package/src/cli/safe-live.ts +0 -24
- package/src/cli/status-filter.ts +0 -114
- package/src/cli/util/api-context.ts +0 -85
- package/src/cli/version.ts +0 -8
- package/src/core/audit/persist.ts +0 -183
- package/src/core/checks/budget.ts +0 -59
- package/src/core/checks/checks/_crud-helpers.ts +0 -133
- package/src/core/checks/checks/_negative_mutator.ts +0 -133
- package/src/core/checks/checks/_readback-helpers.ts +0 -133
- package/src/core/checks/checks/content_type_conformance.ts +0 -39
- package/src/core/checks/checks/cross_call_references.ts +0 -147
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +0 -219
- package/src/core/checks/checks/ensure_resource_availability.ts +0 -62
- package/src/core/checks/checks/idempotency_replay.ts +0 -242
- package/src/core/checks/checks/ignored_auth.ts +0 -254
- package/src/core/checks/checks/index.ts +0 -68
- package/src/core/checks/checks/lifecycle_transitions.ts +0 -416
- package/src/core/checks/checks/missing_required_header.ts +0 -40
- package/src/core/checks/checks/negative_data_rejection.ts +0 -148
- package/src/core/checks/checks/not_a_server_error.ts +0 -35
- package/src/core/checks/checks/open_cors_on_sensitive.ts +0 -160
- package/src/core/checks/checks/pagination_invariants.ts +0 -419
- package/src/core/checks/checks/positive_data_acceptance.ts +0 -33
- package/src/core/checks/checks/rate_limit_headers_absent.ts +0 -77
- package/src/core/checks/checks/response_headers_conformance.ts +0 -74
- package/src/core/checks/checks/response_schema_conformance.ts +0 -30
- package/src/core/checks/checks/status_code_conformance.ts +0 -132
- package/src/core/checks/checks/unsupported_method.ts +0 -63
- package/src/core/checks/checks/use_after_free.ts +0 -78
- package/src/core/checks/index.ts +0 -30
- package/src/core/checks/mode.ts +0 -82
- package/src/core/checks/recommended-action.ts +0 -68
- package/src/core/checks/registry.ts +0 -78
- package/src/core/checks/runner.ts +0 -1461
- package/src/core/checks/sarif.ts +0 -230
- package/src/core/checks/spec-findings.ts +0 -308
- package/src/core/checks/stateful.ts +0 -121
- package/src/core/checks/types.ts +0 -305
- package/src/core/checks/zond-extensions.ts +0 -73
- package/src/core/classifier/recommended-action.ts +0 -251
- package/src/core/context/current.ts +0 -51
- package/src/core/context/session.ts +0 -78
- package/src/core/coverage/loader.ts +0 -216
- package/src/core/coverage/reasons.ts +0 -300
- package/src/core/diagnostics/db-analysis.ts +0 -666
- package/src/core/diagnostics/failure-class.ts +0 -140
- package/src/core/diagnostics/failure-hints.ts +0 -112
- package/src/core/diagnostics/spec-pointer.ts +0 -99
- package/src/core/diagnostics/suggested-fixes.ts +0 -155
- package/src/core/exporter/case-study/index.ts +0 -270
- package/src/core/exporter/curl.ts +0 -40
- package/src/core/exporter/exporter.ts +0 -48
- package/src/core/exporter/html-report/escape.ts +0 -24
- package/src/core/exporter/html-report/index.ts +0 -479
- package/src/core/exporter/html-report/script.ts +0 -100
- package/src/core/exporter/html-report/styles.ts +0 -408
- package/src/core/generator/catalog-builder.ts +0 -179
- package/src/core/generator/chunker.ts +0 -78
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/coverage-scanner.ts +0 -87
- package/src/core/generator/data-factory.ts +0 -759
- package/src/core/generator/describe.ts +0 -302
- package/src/core/generator/endpoint-warnings.ts +0 -52
- package/src/core/generator/fixtures-builder.ts +0 -332
- package/src/core/generator/index.ts +0 -14
- package/src/core/generator/openapi-reader.ts +0 -297
- package/src/core/generator/path-param-disambig.ts +0 -140
- package/src/core/generator/resources-builder.ts +0 -898
- package/src/core/generator/schema-utils.ts +0 -112
- package/src/core/generator/serializer.ts +0 -343
- package/src/core/generator/suite-generator.ts +0 -1301
- package/src/core/generator/types.ts +0 -63
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/lint/affects.ts +0 -28
- package/src/core/lint/config.ts +0 -96
- package/src/core/lint/format.ts +0 -42
- package/src/core/lint/index.ts +0 -94
- package/src/core/lint/reporter.ts +0 -128
- package/src/core/lint/rules/consistency.ts +0 -158
- package/src/core/lint/rules/heuristics.ts +0 -97
- package/src/core/lint/rules/strictness.ts +0 -109
- package/src/core/lint/types.ts +0 -96
- package/src/core/lint/walker.ts +0 -248
- package/src/core/meta/meta-store.ts +0 -11
- package/src/core/output/README.md +0 -73
- package/src/core/output/index.ts +0 -13
- package/src/core/output/run.ts +0 -91
- package/src/core/output/types.ts +0 -122
- package/src/core/parser/dynamic-values.ts +0 -160
- package/src/core/parser/env-interpolation.ts +0 -104
- package/src/core/parser/filter.ts +0 -97
- package/src/core/parser/schema.ts +0 -359
- package/src/core/parser/types.ts +0 -117
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +0 -181
- package/src/core/probe/bootstrap.ts +0 -34
- package/src/core/probe/dry-run-envelope.ts +0 -61
- package/src/core/probe/mass-assignment/classify.ts +0 -175
- package/src/core/probe/mass-assignment/cleanup.ts +0 -52
- package/src/core/probe/mass-assignment/digest.ts +0 -114
- package/src/core/probe/mass-assignment/orchestrator.ts +0 -459
- package/src/core/probe/mass-assignment/regression.ts +0 -141
- package/src/core/probe/mass-assignment/suspects.ts +0 -92
- package/src/core/probe/mass-assignment/types.ts +0 -135
- package/src/core/probe/mass-assignment-probe-class.ts +0 -198
- package/src/core/probe/mass-assignment-probe.ts +0 -27
- package/src/core/probe/mass-assignment-template.ts +0 -240
- package/src/core/probe/method-probe.ts +0 -164
- package/src/core/probe/method-shared.ts +0 -69
- package/src/core/probe/negative-probe.ts +0 -691
- package/src/core/probe/orphan-tracker.ts +0 -188
- package/src/core/probe/path-discovery.ts +0 -439
- package/src/core/probe/probe-harness.ts +0 -119
- package/src/core/probe/registry.ts +0 -89
- package/src/core/probe/runner.ts +0 -136
- package/src/core/probe/security/baseline.ts +0 -174
- package/src/core/probe/security/classify.ts +0 -341
- package/src/core/probe/security/cleanup.ts +0 -125
- package/src/core/probe/security/detectors.ts +0 -71
- package/src/core/probe/security/digest.ts +0 -104
- package/src/core/probe/security/orchestrator.ts +0 -398
- package/src/core/probe/security/regression.ts +0 -103
- package/src/core/probe/security/types.ts +0 -151
- package/src/core/probe/security-probe-class.ts +0 -207
- package/src/core/probe/security-probe.ts +0 -32
- package/src/core/probe/shared.ts +0 -531
- package/src/core/probe/static-probe-class.ts +0 -125
- package/src/core/probe/types.ts +0 -165
- package/src/core/probe/verdict-aggregator.ts +0 -33
- package/src/core/probe/webhooks-probe.ts +0 -282
- package/src/core/reporter/console.ts +0 -240
- package/src/core/reporter/index.ts +0 -22
- package/src/core/reporter/json.ts +0 -22
- package/src/core/reporter/junit.ts +0 -93
- package/src/core/reporter/ndjson.ts +0 -37
- package/src/core/reporter/types.ts +0 -15
- package/src/core/runner/assertions.ts +0 -383
- package/src/core/runner/async-pool.ts +0 -108
- package/src/core/runner/auth-path.ts +0 -8
- package/src/core/runner/ci-context.ts +0 -72
- package/src/core/runner/executor.ts +0 -652
- package/src/core/runner/expr-eval.ts +0 -41
- package/src/core/runner/form-encode.ts +0 -41
- package/src/core/runner/http-client.ts +0 -224
- package/src/core/runner/learn-drift.ts +0 -293
- package/src/core/runner/preflight-vars.ts +0 -153
- package/src/core/runner/progress-tracker.ts +0 -73
- package/src/core/runner/rate-limiter.ts +0 -185
- package/src/core/runner/run-kind.ts +0 -45
- package/src/core/runner/schema-validator.ts +0 -308
- package/src/core/runner/send-request.ts +0 -232
- package/src/core/runner/transforms.ts +0 -65
- package/src/core/runner/types.ts +0 -94
- package/src/core/secrets/registry.ts +0 -164
- package/src/core/secrets/secrets-file.ts +0 -115
- package/src/core/selectors/operation-filter.ts +0 -144
- package/src/core/setup-api.ts +0 -590
- package/src/core/severity/category.ts +0 -94
- package/src/core/severity/index.ts +0 -58
- package/src/core/spec/infer-schema.ts +0 -102
- package/src/core/spec/layers.ts +0 -154
- package/src/core/spec/merge-specs.ts +0 -156
- package/src/core/spec/schema-from-runs.ts +0 -117
- package/src/core/spec/schema-overlay.ts +0 -130
- package/src/core/util/ajv.ts +0 -13
- package/src/core/util/format-eta.ts +0 -21
- package/src/core/util/headers.ts +0 -9
- package/src/core/util/url.ts +0 -24
- package/src/core/utils.ts +0 -13
- package/src/core/workspace/config.ts +0 -129
- package/src/core/workspace/fixture-gap-report.ts +0 -84
- package/src/core/workspace/fixture-gaps.ts +0 -71
- package/src/core/workspace/manifest.ts +0 -283
- package/src/core/workspace/output-rotation.ts +0 -62
- package/src/core/workspace/root.ts +0 -96
- package/src/core/workspace/triage-path.ts +0 -87
- package/src/db/lint-runs.ts +0 -47
- package/src/db/migrate.ts +0 -128
- package/src/db/migrations/0001_run_kind.sql +0 -25
- package/src/db/migrations/0002_run_kind_request.sql +0 -59
- package/src/db/migrations/sql.d.ts +0 -4
- package/src/db/queries/collections.ts +0 -133
- package/src/db/queries/coverage.ts +0 -9
- package/src/db/queries/dashboard.ts +0 -59
- package/src/db/queries/results.ts +0 -216
- package/src/db/queries/runs.ts +0 -289
- package/src/db/queries/sessions.ts +0 -42
- package/src/db/queries/settings.ts +0 -28
- package/src/db/queries/types.ts +0 -172
- package/src/db/queries.ts +0 -75
- package/src/db/schema.ts +0 -298
|
@@ -1,1301 +0,0 @@
|
|
|
1
|
-
import type { OpenAPIV3 } from "openapi-types";
|
|
2
|
-
import type { EndpointInfo, SecuritySchemeInfo, CrudGroup } from "./types.ts";
|
|
3
|
-
import type { RawSuite, RawStep } from "./serializer.ts";
|
|
4
|
-
import type { SourceMetadata } from "../parser/types.ts";
|
|
5
|
-
import { generateFromSchema, generateMultipartFromSchema, isFkFixtureField, canonicalVarName, effectiveObjectShape } from "./data-factory.ts";
|
|
6
|
-
import { groupEndpointsByTag } from "./chunker.ts";
|
|
7
|
-
import { getAuthHeaders as sharedGetAuthHeaders } from "../probe/shared.ts";
|
|
8
|
-
import { flattenToFormFields } from "../runner/form-encode.ts";
|
|
9
|
-
|
|
10
|
-
// ──────────────────────────────────────────────
|
|
11
|
-
// Helpers
|
|
12
|
-
// ──────────────────────────────────────────────
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Singularize an English plural noun for use in suite names and capture
|
|
16
|
-
* variables. Handles the cases that matter for typical OpenAPI resource
|
|
17
|
-
* names — `properties → property`, `addresses → address`, `boxes → box`,
|
|
18
|
-
* `users → user`. Words that don't match any rule are returned unchanged
|
|
19
|
-
* (so already-singular `series`, `news`, `data`, etc. survive).
|
|
20
|
-
*/
|
|
21
|
-
export function singularizeResource(word: string): string {
|
|
22
|
-
if (word.length > 3 && /ies$/i.test(word)) return word.slice(0, -3) + "y";
|
|
23
|
-
// ARV-100 (F5): the inner alternative was `s` — but a single trailing `s`
|
|
24
|
-
// catches every regular plural whose stem ends in any vowel + `s` (e.g.
|
|
25
|
-
// `releases`, `phases`, `houses`), and `slice(-2)` then chops "es" instead
|
|
26
|
-
// of just "s". The result was `releas_id` / `phas_id` capture vars that
|
|
27
|
-
// matched nothing on the manifest side. Restrict the rule to the genuine
|
|
28
|
-
// sibilant double — `ss` — so `addresses → address` keeps working without
|
|
29
|
-
// dragging single-s plurals along.
|
|
30
|
-
if (word.length > 3 && /(ch|sh|x|ss|z)es$/i.test(word)) return word.slice(0, -2);
|
|
31
|
-
if (word.length > 1 && /[^s]s$/i.test(word)) return word.slice(0, -1);
|
|
32
|
-
return word;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Build a `<resource>_id` capture/var name. Strips dashes so the result is
|
|
37
|
-
* a safe template variable identifier — `contact-properties` becomes
|
|
38
|
-
* `contact_property_id` rather than `contact-propertie_id` (TASK-214).
|
|
39
|
-
*
|
|
40
|
-
* ARV-100 (F5): always lowercase. Path-params/headers in fixtures-builder
|
|
41
|
-
* are normalised to lowercase (line 157), so capture vars must match — a
|
|
42
|
-
* `Groups` resource would otherwise produce `Group_id` while path-params on
|
|
43
|
-
* the same endpoint produce `group_id`, splitting the `{{var}}` namespace
|
|
44
|
-
* and triggering "Undefined variables" in `zond run`.
|
|
45
|
-
*/
|
|
46
|
-
export function resourceVar(resource: string, suffix: string): string {
|
|
47
|
-
const singular = singularizeResource(resource);
|
|
48
|
-
return `${singular.replace(/[^a-zA-Z0-9]+/g, "_")}_${suffix}`.toLowerCase();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Convert OpenAPI path params {param} to test interpolation {{param}}.
|
|
52
|
-
* When `ambiguous` is given (ARV-369), a param name reused across
|
|
53
|
-
* distinct resources becomes a resource-scoped var (`{{templates_code}}`)
|
|
54
|
-
* instead of the raw name, so `.env.yaml` can hold distinct values per
|
|
55
|
-
* resource instead of one value silently applying everywhere. */
|
|
56
|
-
function convertPath(path: string, ambiguous?: Set<string>): string {
|
|
57
|
-
if (!ambiguous || ambiguous.size === 0) {
|
|
58
|
-
return path.replace(/\{([^}]+)\}/g, "{{$1}}");
|
|
59
|
-
}
|
|
60
|
-
return path.replace(/\{([^}]+)\}/g, (_, name) => `{{${fixtureVarNameForPathParam(path, name, ambiguous)}}}`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Convert path params to seed values for smoke suites (no capture context).
|
|
65
|
-
* Uses the parameter's example/default from the spec, or falls back to
|
|
66
|
-
* {{placeholder}} form so the user fills them in via .env.yaml.
|
|
67
|
-
*/
|
|
68
|
-
function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
|
|
69
|
-
return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
70
|
-
const param = ep.parameters.find(p => p.name === name && p.in === "path");
|
|
71
|
-
const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
72
|
-
const example = (param as any)?.example ?? schema?.example ?? schema?.default;
|
|
73
|
-
if (example !== undefined) return String(example);
|
|
74
|
-
return `{{${name}}}`;
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* For negative-smoke suites: replace path params with guaranteed-non-existent values.
|
|
80
|
-
* Picks a value that's syntactically valid for the param's type/format but very
|
|
81
|
-
* unlikely to match a real resource (zero-UUID, very large int, sentinel string).
|
|
82
|
-
*/
|
|
83
|
-
function getNonexistentSeed(schema: OpenAPIV3.SchemaObject | undefined): string {
|
|
84
|
-
if (!schema) return "nonexistent_id_zzzzzz";
|
|
85
|
-
if (schema.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
|
86
|
-
if (schema.type === "integer" || schema.type === "number") return "999999999";
|
|
87
|
-
return "nonexistent_id_zzzzzz";
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function convertPathWithBadIds(path: string, ep: EndpointInfo): string {
|
|
91
|
-
return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
92
|
-
const param = ep.parameters.find(p => p.name === name && p.in === "path");
|
|
93
|
-
const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
94
|
-
return getNonexistentSeed(schema);
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function endpointHasPathParams(ep: EndpointInfo): boolean {
|
|
99
|
-
return ep.parameters.some(p => p.in === "path");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function slugify(s: string): string {
|
|
103
|
-
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Strip trailing API-version path segments (`v1`, `v30`, `version2`) so
|
|
107
|
-
* resource-name derivation doesn't mistake a version marker for the
|
|
108
|
-
* resource itself on APIs shaped `/api/<resource>/v{N}` (ARV-372). */
|
|
109
|
-
export function stripTrailingVersionSegments(segments: string[]): string[] {
|
|
110
|
-
const out = [...segments];
|
|
111
|
-
while (out.length > 1 && /^v(ersion)?\d+$/i.test(out[out.length - 1]!)) {
|
|
112
|
-
out.pop();
|
|
113
|
-
}
|
|
114
|
-
return out;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Owning collection for a path-param: the last non-version path segment
|
|
118
|
-
* before its `{name}` placeholder. Used to disambiguate a path-param name
|
|
119
|
-
* (`code`, `id`, `key`) that's reused across genuinely distinct resources
|
|
120
|
-
* (ARV-369) — e.g. `/api/templates/v30/{code}` → "templates" vs
|
|
121
|
-
* `/api/macros/v30/{code}` → "macros". */
|
|
122
|
-
// ARV-376: read-by-id accessor markers — kept in sync with the set in
|
|
123
|
-
// path-param-disambig.ts. `/business-segment20/byid/{id}` is owned by
|
|
124
|
-
// `business-segment20`, not the `byid` accessor verb.
|
|
125
|
-
const ACCESSOR_MARKER_SEGS = new Set(["byid", "by-id", "by_id"]);
|
|
126
|
-
|
|
127
|
-
export function owningCollectionForPathParam(path: string, paramName: string): string | null {
|
|
128
|
-
const marker = `{${paramName}}`;
|
|
129
|
-
const idx = path.indexOf(marker);
|
|
130
|
-
if (idx === -1) return null;
|
|
131
|
-
const segments = stripTrailingVersionSegments(path.slice(0, idx).split("/").filter(Boolean));
|
|
132
|
-
// Skip trailing accessor markers so the owner is the collection, not the
|
|
133
|
-
// `byid`/`by-code` verb — otherwise a param behind `/byid/{id}` and its
|
|
134
|
-
// sibling `DELETE /{id}` disagree on owner and get double-scoped.
|
|
135
|
-
let end = segments.length - 1;
|
|
136
|
-
while (end >= 0 && ACCESSOR_MARKER_SEGS.has(segments[end]!.toLowerCase())) end--;
|
|
137
|
-
return end >= 0 ? segments[end]! : null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Path-param names reused by more than one distinct owning collection
|
|
141
|
-
* across the whole spec — these need a resource-scoped fixture var name
|
|
142
|
-
* (`templates_code`) instead of the raw name (`code`), otherwise one
|
|
143
|
-
* `.env.yaml` value silently applies to unrelated resources (ARV-369). */
|
|
144
|
-
export function computeAmbiguousPathParams(endpoints: EndpointInfo[]): Set<string> {
|
|
145
|
-
const owners = new Map<string, Set<string>>();
|
|
146
|
-
for (const ep of endpoints) {
|
|
147
|
-
for (const p of ep.parameters) {
|
|
148
|
-
if (p.in !== "path" || p.required === false) continue;
|
|
149
|
-
const owner = owningCollectionForPathParam(ep.path, p.name) ?? "";
|
|
150
|
-
if (!owners.has(p.name)) owners.set(p.name, new Set());
|
|
151
|
-
owners.get(p.name)!.add(owner);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const ambiguous = new Set<string>();
|
|
155
|
-
for (const [name, ownerSet] of owners) {
|
|
156
|
-
if (ownerSet.size > 1) ambiguous.add(name);
|
|
157
|
-
}
|
|
158
|
-
return ambiguous;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Resource-scoped fixture var name for a path-param, when ambiguous;
|
|
162
|
-
* otherwise the raw param name (single-owner APIs — the common case —
|
|
163
|
-
* keep producing the same var names as before ARV-369). */
|
|
164
|
-
export function fixtureVarNameForPathParam(
|
|
165
|
-
path: string,
|
|
166
|
-
paramName: string,
|
|
167
|
-
ambiguous: Set<string>,
|
|
168
|
-
): string {
|
|
169
|
-
if (!ambiguous.has(paramName)) return paramName;
|
|
170
|
-
const owner = owningCollectionForPathParam(path, paramName);
|
|
171
|
-
if (!owner) return paramName;
|
|
172
|
-
return `${owner.replace(/[^a-zA-Z0-9]+/g, "_")}_${paramName}`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function escapeRegex(s: string): string {
|
|
176
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const HEALTHCHECK_PATH_RE = /\/(health|healthz|ping|status|ready|readiness|liveness|alive)\b/i;
|
|
180
|
-
const RESET_PATH_RE = /\/(reset|flush|purge|truncate|wipe|clear-data|factory-reset)\b/i;
|
|
181
|
-
const LOGOUT_PATH_RE = /\/(logout|signout|invalidate|revoke)\b/i;
|
|
182
|
-
const SHORT_PATH_RE = /^\/[a-z0-9-]*$/i; // matches /, /api, /v1, etc.
|
|
183
|
-
|
|
184
|
-
function selectHealthcheckEndpoint(gets: EndpointInfo[]): EndpointInfo | undefined {
|
|
185
|
-
return (
|
|
186
|
-
gets.find(ep => HEALTHCHECK_PATH_RE.test(ep.path) && !ep.parameters.some(p => p.in === "path")) ??
|
|
187
|
-
gets.find(ep => SHORT_PATH_RE.test(ep.path) && !ep.parameters.some(p => p.in === "path") && ep.security.length === 0) ??
|
|
188
|
-
gets.find(ep => !ep.parameters.some(p => p.in === "path") && ep.security.length === 0)
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Pick the success status the test should assert.
|
|
194
|
-
*
|
|
195
|
-
* Order:
|
|
196
|
-
* 1. First 2xx declared in the spec (most authoritative).
|
|
197
|
-
* 2. Method-aware default when the spec lists only non-2xx responses or none
|
|
198
|
-
* at all (many OpenAPI specs is silent for several mutating endpoints — the
|
|
199
|
-
* actual runtime returns 201/204, while the old default of 200 caused
|
|
200
|
-
* tests to fail at runtime). We never assert a 4xx/5xx as the success
|
|
201
|
-
* status — that would generate guaranteed-failing tests.
|
|
202
|
-
*/
|
|
203
|
-
function getExpectedStatus(ep: EndpointInfo): number {
|
|
204
|
-
const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
|
|
205
|
-
if (success) return success.statusCode;
|
|
206
|
-
return defaultStatusByMethod(ep.method);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function defaultStatusByMethod(method: string): number {
|
|
210
|
-
switch (method.toUpperCase()) {
|
|
211
|
-
case "POST":
|
|
212
|
-
return 201;
|
|
213
|
-
case "DELETE":
|
|
214
|
-
return 204;
|
|
215
|
-
default:
|
|
216
|
-
return 200;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function getSuccessSchema(ep: EndpointInfo): OpenAPIV3.SchemaObject | undefined {
|
|
221
|
-
return ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300)?.schema;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function getBodyAssertions(ep: EndpointInfo): Record<string, Record<string, string>> | undefined {
|
|
225
|
-
const schema = getSuccessSchema(ep);
|
|
226
|
-
if (!schema) return undefined;
|
|
227
|
-
|
|
228
|
-
if (schema.type === "array") {
|
|
229
|
-
return { _body: { type: "array" } };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (schema.properties) {
|
|
233
|
-
const assertions: Record<string, Record<string, string>> = {};
|
|
234
|
-
const props = Object.keys(schema.properties).slice(0, 5);
|
|
235
|
-
for (const prop of props) {
|
|
236
|
-
assertions[prop] = { exists: "true" };
|
|
237
|
-
}
|
|
238
|
-
return Object.keys(assertions).length > 0 ? assertions : undefined;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return undefined;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Derive a variable name for a security scheme's token */
|
|
245
|
-
export function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
|
|
246
|
-
// Count how many bearer-like schemes exist
|
|
247
|
-
const bearerSchemes = allSchemes.filter(s =>
|
|
248
|
-
(s.type === "http" && (s.scheme === "bearer" || !s.scheme)) ||
|
|
249
|
-
(s.type === "apiKey" && s.in === "header" && s.apiKeyName === "Authorization")
|
|
250
|
-
);
|
|
251
|
-
// If only one bearer scheme → keep generic auth_token for backward compat
|
|
252
|
-
if (bearerSchemes.length <= 1) return "auth_token";
|
|
253
|
-
// Multiple → derive from scheme name (e.g. "platformAuth" → "platform_auth_token")
|
|
254
|
-
const slug = scheme.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/_token$|_auth$/, "");
|
|
255
|
-
return `${slug}_token`;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function getAuthHeaders(
|
|
259
|
-
ep: EndpointInfo,
|
|
260
|
-
schemes: SecuritySchemeInfo[],
|
|
261
|
-
): Record<string, string> | undefined {
|
|
262
|
-
return sharedGetAuthHeaders(ep, schemes, s => schemeVarName(s, schemes));
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function getRequiredQueryParams(ep: EndpointInfo): Record<string, string> | undefined {
|
|
266
|
-
const queryParams = ep.parameters.filter(p => p.in === "query" && p.required);
|
|
267
|
-
if (queryParams.length === 0) return undefined;
|
|
268
|
-
|
|
269
|
-
const query: Record<string, string> = {};
|
|
270
|
-
for (const p of queryParams) {
|
|
271
|
-
if (p.schema) {
|
|
272
|
-
const val = generateFromSchema(p.schema as OpenAPIV3.SchemaObject, p.name);
|
|
273
|
-
query[p.name] = typeof val === "object" ? JSON.stringify(val) : String(val);
|
|
274
|
-
} else {
|
|
275
|
-
query[p.name] = "{{$randomString}}";
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
return query;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** Check if all endpoints share the same auth headers → suite-level */
|
|
282
|
-
function getSuiteHeaders(
|
|
283
|
-
endpoints: EndpointInfo[],
|
|
284
|
-
schemes: SecuritySchemeInfo[],
|
|
285
|
-
): Record<string, string> | undefined {
|
|
286
|
-
if (endpoints.length === 0) return undefined;
|
|
287
|
-
|
|
288
|
-
const headerSets = endpoints.map(ep => getAuthHeaders(ep, schemes));
|
|
289
|
-
const first = headerSets[0];
|
|
290
|
-
if (!first) {
|
|
291
|
-
// ARV-212 (R13/F16): spec has no securitySchemes (GitHub publishes its
|
|
292
|
-
// OpenAPI this way) so per-endpoint auth-header derivation returns
|
|
293
|
-
// undefined for every step. When the API workspace nonetheless wires
|
|
294
|
-
// `auth_token` end-to-end (ARV-201 seeds it in .env.yaml on bare specs,
|
|
295
|
-
// and zond request / runner auto-attach Authorization: Bearer when
|
|
296
|
-
// auth_token is present), generated suites should not silently go
|
|
297
|
-
// unauth — that bricks them on the first rate-limited 60 requests.
|
|
298
|
-
// Fall back to a generic Bearer header at the suite level. The header
|
|
299
|
-
// is harmless when .secrets.yaml.auth_token is empty (zond runner
|
|
300
|
-
// still substitutes `{{auth_token}}` to an empty string, just like
|
|
301
|
-
// before; the server then 401s — same outcome as today).
|
|
302
|
-
if (schemes.length === 0 && _suiteDefaultAuthVar !== null) {
|
|
303
|
-
return { Authorization: `Bearer {{${_suiteDefaultAuthVar}}}` };
|
|
304
|
-
}
|
|
305
|
-
return undefined;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const firstJson = JSON.stringify(first);
|
|
309
|
-
const allSame = headerSets.every(h => JSON.stringify(h) === firstJson);
|
|
310
|
-
return allSame ? first : undefined;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// ARV-212 (R13/F16): generator-level "the caller wired auth_token in
|
|
314
|
-
// .env.yaml even though the spec has no securitySchemes" hint. Set at the
|
|
315
|
-
// top of generateSuites and consulted by getSuiteHeaders / generateCrudSuite
|
|
316
|
-
// / generateSanitySuite. Module-scoped to avoid threading through ~7 call
|
|
317
|
-
// sites. Always reset to null at the end of generateSuites so the helper
|
|
318
|
-
// stays stateless from the caller's perspective.
|
|
319
|
-
let _suiteDefaultAuthVar: string | null = null;
|
|
320
|
-
|
|
321
|
-
/** Same module-scoping rationale as `_suiteDefaultAuthVar` above: computed
|
|
322
|
-
* once from the full endpoint list at the top of generateSuites (ARV-369
|
|
323
|
-
* needs the whole spec to detect a param-name collision), consulted by
|
|
324
|
-
* `generateStep`'s path conversion. Reset to empty at the end of
|
|
325
|
-
* generateSuites. */
|
|
326
|
-
let _ambiguousPathParams: Set<string> = new Set();
|
|
327
|
-
|
|
328
|
-
/** Common id-like field names looked up after `id` itself.
|
|
329
|
-
* TASK-139: many real-world APIs return `slug`, `uuid`, `version`, `key`,
|
|
330
|
-
* or `name` instead of an `id` field on create responses. Without these,
|
|
331
|
-
* CRUD chains fall back to capturing `"id"` from a body that doesn't have
|
|
332
|
-
* one, breaking the `{id}` substitution in follow-up reads. */
|
|
333
|
-
const ID_LIKE_NAMES = ["slug", "uuid", "key", "version", "name"];
|
|
334
|
-
|
|
335
|
-
/** Find the best field to capture from POST response (for CRUD chains).
|
|
336
|
-
*
|
|
337
|
-
* Priority:
|
|
338
|
-
* 1. Field whose name matches the path-param (e.g. `{rule_id}` → `rule_id`
|
|
339
|
-
* or `{slug}` → `slug`). The path-param name is the strongest hint —
|
|
340
|
-
* whatever the response calls "the id of this resource" is what gets
|
|
341
|
-
* interpolated back into the read/update/delete URLs.
|
|
342
|
-
* 2. `id` (most common case).
|
|
343
|
-
* 3. Conventional id-like names: `slug`, `uuid`, `key`, `version`, `name`
|
|
344
|
-
* — but only if they are typed as a string (avoids capturing a `name`
|
|
345
|
-
* object on resources that nest metadata).
|
|
346
|
-
* 4. Any field with `type: integer` or `format: uuid`.
|
|
347
|
-
* 5. Fallback: `"id"` (the YAML capture will simply be empty if absent —
|
|
348
|
-
* the runner already handles this gracefully).
|
|
349
|
-
*/
|
|
350
|
-
function getCaptureField(ep: EndpointInfo, idParam?: string): string {
|
|
351
|
-
const schema = getSuccessSchema(ep);
|
|
352
|
-
const props = schema?.properties;
|
|
353
|
-
if (!props) return "id";
|
|
354
|
-
|
|
355
|
-
// 1. Path-param name match.
|
|
356
|
-
if (idParam) {
|
|
357
|
-
if (idParam in props) return idParam;
|
|
358
|
-
// Also try the conventional `<resource>_id` ↔ `id` swap.
|
|
359
|
-
if (idParam.endsWith("_id") && "id" in props) return "id";
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// 2. Plain `id`.
|
|
363
|
-
if ("id" in props) return "id";
|
|
364
|
-
|
|
365
|
-
// 3. Conventional id-like names (string-typed only).
|
|
366
|
-
for (const candidate of ID_LIKE_NAMES) {
|
|
367
|
-
if (candidate in props) {
|
|
368
|
-
const s = props[candidate] as OpenAPIV3.SchemaObject;
|
|
369
|
-
if (s.type === "string") return candidate;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// 4. Any integer or uuid-shaped field.
|
|
374
|
-
for (const [name, propSchema] of Object.entries(props)) {
|
|
375
|
-
const s = propSchema as OpenAPIV3.SchemaObject;
|
|
376
|
-
if (s.type === "integer" || s.format === "uuid") return name;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
return "id";
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const AUTH_PATH_PATTERNS = /\/(auth|login|signin|signup|register|token|oauth)\b/i;
|
|
383
|
-
|
|
384
|
-
function isAuthEndpoint(ep: EndpointInfo): boolean {
|
|
385
|
-
if (AUTH_PATH_PATTERNS.test(ep.path)) return true;
|
|
386
|
-
if (ep.tags?.some(t => /^auth/i.test(t))) return true;
|
|
387
|
-
return false;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// ──────────────────────────────────────────────
|
|
391
|
-
// Provenance helpers
|
|
392
|
-
// ──────────────────────────────────────────────
|
|
393
|
-
|
|
394
|
-
function escapeJsonPointerSegment(s: string): string {
|
|
395
|
-
return s.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function pickPrimaryStatus(status: number | number[]): number {
|
|
399
|
-
return Array.isArray(status) ? (status[0] ?? 200) : status;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** Build step-level provenance for an endpoint + chosen response status. */
|
|
403
|
-
export function buildStepSource(
|
|
404
|
-
ep: EndpointInfo,
|
|
405
|
-
statusOverride?: number | number[],
|
|
406
|
-
): SourceMetadata {
|
|
407
|
-
const method = ep.method.toUpperCase();
|
|
408
|
-
const status = statusOverride ?? getExpectedStatus(ep);
|
|
409
|
-
const primary = pickPrimaryStatus(status);
|
|
410
|
-
const responseBranch = Array.isArray(status) ? status.map(String).join("|") : String(status);
|
|
411
|
-
const escapedPath = escapeJsonPointerSegment(ep.path);
|
|
412
|
-
return {
|
|
413
|
-
endpoint: `${method} ${ep.path}`,
|
|
414
|
-
response_branch: responseBranch,
|
|
415
|
-
schema_pointer: `#/paths/${escapedPath}/${method.toLowerCase()}/responses/${primary}`,
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** Build suite-level provenance for an openapi-generated suite. */
|
|
420
|
-
export function buildOpenApiSuiteSource(specPath?: string): SourceMetadata | undefined {
|
|
421
|
-
if (!specPath) return undefined;
|
|
422
|
-
return {
|
|
423
|
-
type: "openapi-generated",
|
|
424
|
-
spec: specPath,
|
|
425
|
-
generator: "zond-generate",
|
|
426
|
-
generated_at: new Date().toISOString(),
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// ──────────────────────────────────────────────
|
|
431
|
-
// Public API
|
|
432
|
-
// ──────────────────────────────────────────────
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* ARV-45: swap FK-shaped required body fields for `{{fixture}}` references so
|
|
436
|
-
* their values come from `.env.yaml` instead of `{{$randomString}}`/`{{$uuid}}`
|
|
437
|
-
* junk that 400s and kills the CRUD chain at step 1. Field names that motivated
|
|
438
|
-
* this — `sequenceTypeCode`, `templateGroupCode` — are closed-vocab codes the
|
|
439
|
-
* generator can't guess. The var name is canonicalised (`sequence_type_code`)
|
|
440
|
-
* to match the manifest entry (fixtures-builder step 5); the HTTP body key
|
|
441
|
-
* keeps the raw spec spelling. Recurses into nested objects for nested FKs.
|
|
442
|
-
*
|
|
443
|
-
* Keep the detection predicate (`isFkFixtureField`) in lockstep with the
|
|
444
|
-
* manifest builder — every var the tests reference must appear in the manifest
|
|
445
|
-
* (decision-7).
|
|
446
|
-
*/
|
|
447
|
-
export function wireBodyFkRefs(schema: OpenAPIV3.SchemaObject, body: unknown): unknown {
|
|
448
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
449
|
-
// `effectiveObjectShape` merges allOf so allOf-wrapped bodies (the .NET/
|
|
450
|
-
// Swagger norm) resolve their properties — a direct schema.properties read
|
|
451
|
-
// misses every FK on those specs.
|
|
452
|
-
const { properties, required } = effectiveObjectShape(schema);
|
|
453
|
-
const out = body as Record<string, unknown>;
|
|
454
|
-
for (const [name, propSchema] of Object.entries(properties)) {
|
|
455
|
-
if (!(name in out)) continue;
|
|
456
|
-
if (required.has(name) && isFkFixtureField(name, propSchema)) {
|
|
457
|
-
out[name] = `{{${canonicalVarName(name)}}}`;
|
|
458
|
-
} else if (out[name] && typeof out[name] === "object" && !Array.isArray(out[name])) {
|
|
459
|
-
out[name] = wireBodyFkRefs(propSchema, out[name]);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
return out;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/** Generate a request body from a schema with FK fields wired to fixtures. */
|
|
466
|
-
function generateBody(schema: OpenAPIV3.SchemaObject): unknown {
|
|
467
|
-
return wireBodyFkRefs(schema, generateFromSchema(schema));
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/** Generate a single test step from an EndpointInfo */
|
|
471
|
-
export function generateStep(
|
|
472
|
-
ep: EndpointInfo,
|
|
473
|
-
securitySchemes: SecuritySchemeInfo[],
|
|
474
|
-
): RawStep {
|
|
475
|
-
const method = ep.method.toUpperCase();
|
|
476
|
-
const name = ep.operationId ?? ep.summary ?? `${method} ${ep.path}`;
|
|
477
|
-
const path = convertPath(ep.path, _ambiguousPathParams);
|
|
478
|
-
|
|
479
|
-
const step: RawStep = {
|
|
480
|
-
name,
|
|
481
|
-
source: buildStepSource(ep),
|
|
482
|
-
[method]: path,
|
|
483
|
-
expect: {
|
|
484
|
-
status: getExpectedStatus(ep),
|
|
485
|
-
},
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
const authHeaders = getAuthHeaders(ep, securitySchemes);
|
|
489
|
-
if (authHeaders) {
|
|
490
|
-
step.headers = authHeaders;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (["POST", "PUT", "PATCH"].includes(method) && ep.requestBodySchema) {
|
|
494
|
-
if (ep.requestBodyContentType === "multipart/form-data") {
|
|
495
|
-
step.multipart = generateMultipartFromSchema(ep.requestBodySchema);
|
|
496
|
-
} else if (ep.requestBodyContentType === "application/x-www-form-urlencoded") {
|
|
497
|
-
// ARV-149: form-encoded endpoints (Stripe v1 et al.) — emit `form:` so
|
|
498
|
-
// the runner posts URL-encoded bodies with bracket notation. Without
|
|
499
|
-
// this, generate baked `json:` blocks and every POST 400'd with
|
|
500
|
-
// "wrong content type".
|
|
501
|
-
step.form = flattenToFormFields(generateBody(ep.requestBodySchema));
|
|
502
|
-
} else {
|
|
503
|
-
step.json = generateBody(ep.requestBodySchema);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const query = getRequiredQueryParams(ep);
|
|
508
|
-
if (query) {
|
|
509
|
-
step.query = query;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
const body = getBodyAssertions(ep);
|
|
513
|
-
if (body) {
|
|
514
|
-
step.expect.body = body;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
return step;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/** Strip a single trailing slash for comparison purposes. We never rewrite
|
|
521
|
-
* endpoint paths in the spec — we just normalise the matching regex so
|
|
522
|
-
* `POST /alerts/` + `GET /alerts/{id}/` lines up the same as the no-slash
|
|
523
|
-
* variant. */
|
|
524
|
-
function stripTrailingSlash(p: string): string {
|
|
525
|
-
return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/** Per-resource diagnostic record used by `zond generate --explain`.
|
|
529
|
-
* Captures every POST candidate the detector considered and the verdict
|
|
530
|
-
* with a human reason — so users can see "I have a CRUD-looking pair, why
|
|
531
|
-
* didn't generate emit a chain?" without grepping the spec. */
|
|
532
|
-
export interface CrudDetectionDiagnostic {
|
|
533
|
-
resource: string;
|
|
534
|
-
basePath: string;
|
|
535
|
-
postPath: string;
|
|
536
|
-
hasGetById: boolean;
|
|
537
|
-
hasUpdate: boolean;
|
|
538
|
-
hasDelete: boolean;
|
|
539
|
-
hasList: boolean;
|
|
540
|
-
verdict: "chain" | "skipped";
|
|
541
|
-
reason: string;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
export interface DetectCrudResult {
|
|
545
|
-
groups: CrudGroup[];
|
|
546
|
-
diagnostics: CrudDetectionDiagnostic[];
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/** Detect CRUD groups from a list of endpoints.
|
|
550
|
-
*
|
|
551
|
-
* Match logic (TASK-139):
|
|
552
|
-
* - basePath = POST endpoint's path with any trailing slash trimmed.
|
|
553
|
-
* - item path = `<basePath>/{param}` with optional trailing slash.
|
|
554
|
-
* This catches common SaaS-style `POST /alert-rules/` + `GET /alert-rules/{id}/`
|
|
555
|
-
* pairs that previously fell through because the regex required the same
|
|
556
|
-
* slash form on both. */
|
|
557
|
-
export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
|
|
558
|
-
return detectCrudGroupsWithDiagnostics(endpoints).groups;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
export function detectCrudGroupsWithDiagnostics(
|
|
562
|
-
endpoints: EndpointInfo[],
|
|
563
|
-
): DetectCrudResult {
|
|
564
|
-
const groups: CrudGroup[] = [];
|
|
565
|
-
const diagnostics: CrudDetectionDiagnostic[] = [];
|
|
566
|
-
const postEndpoints = endpoints.filter(
|
|
567
|
-
ep => ep.method.toUpperCase() === "POST" && !ep.deprecated,
|
|
568
|
-
);
|
|
569
|
-
|
|
570
|
-
for (const createEp of postEndpoints) {
|
|
571
|
-
const basePath = stripTrailingSlash(createEp.path);
|
|
572
|
-
// ARV-372: skip trailing version segments (`v1`, `v30`) before taking the
|
|
573
|
-
// last segment as the resource name — otherwise every collection on a
|
|
574
|
-
// spec shaped `/api/<resource>/v{N}` (a common convention) resolves to
|
|
575
|
-
// the SAME resource name ("v30"), and multiple CRUD suites collide on
|
|
576
|
-
// the same `crud-<resource>.yaml` output filename, silently overwriting
|
|
577
|
-
// each other.
|
|
578
|
-
const resource = stripTrailingVersionSegments(basePath.split("/").filter(Boolean)).pop() ?? "resource";
|
|
579
|
-
|
|
580
|
-
// Match `<basePath>/{param}` with optional trailing slash. Tolerates
|
|
581
|
-
// both `POST /alerts/` + `GET /alerts/{id}` and `POST /alerts` +
|
|
582
|
-
// `GET /alerts/{id}/`, which some real-world specs mix.
|
|
583
|
-
const itemPattern = new RegExp(`^${escapeRegex(basePath)}/\\{([^}]+)\\}/?$`);
|
|
584
|
-
const itemEndpoints = endpoints.filter(
|
|
585
|
-
ep => !ep.deprecated && itemPattern.test(ep.path),
|
|
586
|
-
);
|
|
587
|
-
|
|
588
|
-
// Fallback for "subdomain"/nested-item routing (common SaaS-style):
|
|
589
|
-
// create lives under one root (`/api/0/organizations/{org}/teams/`)
|
|
590
|
-
// but item-path lives under another (`/api/0/teams/{org}/{team}/`).
|
|
591
|
-
// The strict basePath/{id} regex misses these. Match instead by:
|
|
592
|
-
// 1. shared OpenAPI tag with the create operation,
|
|
593
|
-
// 2. terminal {param} matching the singular form of the resource
|
|
594
|
-
// (`{team}` / `{team_id}` / `{team_id_or_slug}`).
|
|
595
|
-
let resolvedItemEndpoints = itemEndpoints;
|
|
596
|
-
if (resolvedItemEndpoints.length === 0) {
|
|
597
|
-
const singular = singularizeResource(resource).toLowerCase();
|
|
598
|
-
const itemTerminalRe = /\{([^}]+)\}\/?$/;
|
|
599
|
-
const matchesResourceParam = (p: string) => {
|
|
600
|
-
const m = p.match(itemTerminalRe);
|
|
601
|
-
if (!m) return false;
|
|
602
|
-
const param = m[1]!.toLowerCase();
|
|
603
|
-
return (
|
|
604
|
-
param === singular ||
|
|
605
|
-
param === `${singular}_id` ||
|
|
606
|
-
param === `${singular}_id_or_slug` ||
|
|
607
|
-
param === `${singular}_slug`
|
|
608
|
-
);
|
|
609
|
-
};
|
|
610
|
-
const createTags = new Set(createEp.tags ?? []);
|
|
611
|
-
const sharedTag = (ep: EndpointInfo) =>
|
|
612
|
-
(ep.tags ?? []).some(t => createTags.has(t));
|
|
613
|
-
|
|
614
|
-
resolvedItemEndpoints = endpoints.filter(
|
|
615
|
-
ep =>
|
|
616
|
-
!ep.deprecated &&
|
|
617
|
-
ep.path !== createEp.path &&
|
|
618
|
-
matchesResourceParam(ep.path) &&
|
|
619
|
-
sharedTag(ep),
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const read = resolvedItemEndpoints.find(ep => ep.method.toUpperCase() === "GET");
|
|
624
|
-
const update = resolvedItemEndpoints.find(
|
|
625
|
-
ep => ["PUT", "PATCH"].includes(ep.method.toUpperCase()),
|
|
626
|
-
);
|
|
627
|
-
const del = resolvedItemEndpoints.find(ep => ep.method.toUpperCase() === "DELETE");
|
|
628
|
-
// List endpoint matches with the same trailing-slash tolerance. ARV-376:
|
|
629
|
-
// also accept a `/list`, `/search`, `/find` verb suffix on the base path
|
|
630
|
-
// (`GET /api/deal-kind20/list`) — common in RPC-flavoured REST specs
|
|
631
|
-
// where the bare collection path has no GET.
|
|
632
|
-
const listStems = [basePath, `${basePath}/list`, `${basePath}/search`, `${basePath}/find`];
|
|
633
|
-
const list = endpoints.find(
|
|
634
|
-
ep =>
|
|
635
|
-
ep.method.toUpperCase() === "GET" &&
|
|
636
|
-
listStems.includes(stripTrailingSlash(ep.path)) &&
|
|
637
|
-
!ep.deprecated,
|
|
638
|
-
);
|
|
639
|
-
|
|
640
|
-
const diag: CrudDetectionDiagnostic = {
|
|
641
|
-
resource,
|
|
642
|
-
basePath,
|
|
643
|
-
postPath: createEp.path,
|
|
644
|
-
hasGetById: !!read,
|
|
645
|
-
hasUpdate: !!update,
|
|
646
|
-
hasDelete: !!del,
|
|
647
|
-
hasList: !!list,
|
|
648
|
-
verdict: "skipped",
|
|
649
|
-
reason: "",
|
|
650
|
-
};
|
|
651
|
-
|
|
652
|
-
if (resolvedItemEndpoints.length === 0) {
|
|
653
|
-
diag.reason = `no item endpoint matching ${basePath}/{...}`;
|
|
654
|
-
diagnostics.push(diag);
|
|
655
|
-
continue;
|
|
656
|
-
}
|
|
657
|
-
// TASK-260: accept headless chains — POST + (GET/PUT/PATCH/DELETE on /{id}).
|
|
658
|
-
// Resources with no GET-by-id (e.g. external-teams, some user-binding endpoints)
|
|
659
|
-
// were previously skipped entirely, even though POST captures the ID and PUT/DELETE
|
|
660
|
-
// can drive the chain on their own. The Read/Verify steps in the suite generator
|
|
661
|
-
// are already conditional on `group.read`, so headless chains generate cleanly.
|
|
662
|
-
if (!read && !update && !del) {
|
|
663
|
-
diag.reason = "item endpoint exists but no GET/PUT/PATCH/DELETE on /{id}";
|
|
664
|
-
diagnostics.push(diag);
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const itemPath = resolvedItemEndpoints[0]!.path;
|
|
669
|
-
const idMatch = itemPath.match(/\{([^}]+)\}\/?$/);
|
|
670
|
-
if (!idMatch) {
|
|
671
|
-
diag.reason = "item path has no terminal {param}";
|
|
672
|
-
diagnostics.push(diag);
|
|
673
|
-
continue;
|
|
674
|
-
}
|
|
675
|
-
const idParam = idMatch[1]!;
|
|
676
|
-
|
|
677
|
-
diag.verdict = "chain";
|
|
678
|
-
if (read) {
|
|
679
|
-
diag.reason = "POST + GET/{id} matched";
|
|
680
|
-
} else {
|
|
681
|
-
// TASK-260: explicit headless reason so `--explain` differentiates the
|
|
682
|
-
// two chain shapes — useful for debugging fixture flow.
|
|
683
|
-
const partner = update ? `${update.method.toUpperCase()}/{id}` : `DELETE/{id}`;
|
|
684
|
-
diag.reason = `POST + ${partner} matched (headless: no GET-by-id)`;
|
|
685
|
-
}
|
|
686
|
-
diagnostics.push(diag);
|
|
687
|
-
|
|
688
|
-
groups.push({
|
|
689
|
-
resource,
|
|
690
|
-
basePath,
|
|
691
|
-
itemPath,
|
|
692
|
-
idParam,
|
|
693
|
-
create: createEp,
|
|
694
|
-
list,
|
|
695
|
-
read,
|
|
696
|
-
update,
|
|
697
|
-
delete: del,
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
return { groups, diagnostics };
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/** Generate a CRUD chain suite from a CrudGroup */
|
|
705
|
-
/** ARV-368: does the create's success response actually carry the capture
|
|
706
|
-
* field? If not — a 204 no-body create, or a response schema without the id —
|
|
707
|
-
* the runtime capture is empty and `{{captureVar}}` falls back to the
|
|
708
|
-
* read-fixture of the SAME name (ARV-137 deliberately shares it). A PUT/DELETE
|
|
709
|
-
* then targets a *pre-existing* resource whose id the user harvested for read
|
|
710
|
-
* coverage — silent data-loss. Gate mutating chain steps on this so the suite
|
|
711
|
-
* can only ever update/delete what it actually captured, never live data. */
|
|
712
|
-
function createCapturesId(
|
|
713
|
-
create: EndpointInfo | undefined,
|
|
714
|
-
captureField: string,
|
|
715
|
-
): boolean {
|
|
716
|
-
if (!create) return false;
|
|
717
|
-
const props = getSuccessSchema(create)?.properties;
|
|
718
|
-
return !!props && captureField in props;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
export function generateCrudSuite(
|
|
722
|
-
group: CrudGroup,
|
|
723
|
-
securitySchemes: SecuritySchemeInfo[],
|
|
724
|
-
): RawSuite {
|
|
725
|
-
const captureField = group.create ? getCaptureField(group.create, group.idParam) : "id";
|
|
726
|
-
// ARV-368: only chain PUT/DELETE when the create response yields the id we'd
|
|
727
|
-
// capture. Otherwise the capture is empty at runtime and the mutating step
|
|
728
|
-
// falls back to the shared read-fixture → deletes/overwrites pre-existing data.
|
|
729
|
-
const canChainMutations = createCapturesId(group.create, captureField);
|
|
730
|
-
// ARV-137: use the spec's path-param name as the capture var. Previously
|
|
731
|
-
// we synthesised `<resource>_id` via `resourceVar(...)`, which produced
|
|
732
|
-
// phantom manifest dupes whenever the spec named the path-param anything
|
|
733
|
-
// other than `<resource>_id` (e.g. `monitor_id_or_slug`, `version`, or
|
|
734
|
-
// collection-stem mismatches like resource=`saved`/idParam=`query_id`).
|
|
735
|
-
// Aligning on `group.idParam` keeps tests, manifest, and spec consistent.
|
|
736
|
-
// Fallback to `resourceVar` only when the group has no idParam (defensive
|
|
737
|
-
// — shouldn't happen for any group with a read/update/delete endpoint).
|
|
738
|
-
const captureVar = group.idParam || resourceVar(group.resource, "id");
|
|
739
|
-
const tests: RawStep[] = [];
|
|
740
|
-
|
|
741
|
-
const allEps = [group.create, group.list, group.read, group.update, group.delete].filter(Boolean) as EndpointInfo[];
|
|
742
|
-
const suiteHeaders = getSuiteHeaders(allEps, securitySchemes);
|
|
743
|
-
|
|
744
|
-
// 0. List all (before create, to verify collection exists)
|
|
745
|
-
if (group.list) {
|
|
746
|
-
const step = generateStep(group.list, securitySchemes);
|
|
747
|
-
if (suiteHeaders) delete (step as any).headers;
|
|
748
|
-
tests.push(step);
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
// 1. Create
|
|
752
|
-
if (group.create) {
|
|
753
|
-
const step = generateStep(group.create, securitySchemes);
|
|
754
|
-
if (!step.expect.body) step.expect.body = {};
|
|
755
|
-
step.expect.body[captureField] = { capture: captureVar };
|
|
756
|
-
if (suiteHeaders) delete (step as any).headers;
|
|
757
|
-
tests.push(step);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// 2. Read created
|
|
761
|
-
if (group.read) {
|
|
762
|
-
const step: RawStep = {
|
|
763
|
-
name: group.read.operationId ?? `Read created ${singularizeResource(group.resource)}`,
|
|
764
|
-
source: buildStepSource(group.read),
|
|
765
|
-
GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
766
|
-
expect: {
|
|
767
|
-
status: getExpectedStatus(group.read),
|
|
768
|
-
body: getBodyAssertions(group.read),
|
|
769
|
-
},
|
|
770
|
-
};
|
|
771
|
-
tests.push(step);
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// 3. Update (ARV-368: only if the chain self-captures its id — else the
|
|
775
|
-
// fixture-fallback would overwrite a pre-existing resource)
|
|
776
|
-
if (group.update && canChainMutations) {
|
|
777
|
-
const method = group.update.method.toUpperCase();
|
|
778
|
-
const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
|
|
779
|
-
const etagVar = resourceVar(group.resource, "etag");
|
|
780
|
-
|
|
781
|
-
// If endpoint requires ETag (optimistic locking), capture it from a GET step first
|
|
782
|
-
if (group.update.requiresEtag && group.read) {
|
|
783
|
-
tests.push({
|
|
784
|
-
name: `Get ETag before update ${singularizeResource(group.resource)}`,
|
|
785
|
-
source: buildStepSource(group.read),
|
|
786
|
-
GET: itemPath,
|
|
787
|
-
expect: {
|
|
788
|
-
status: getExpectedStatus(group.read),
|
|
789
|
-
headers: { ETag: { capture: etagVar } },
|
|
790
|
-
},
|
|
791
|
-
});
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const step: RawStep = {
|
|
795
|
-
name: group.update.operationId ?? `Update ${singularizeResource(group.resource)}`,
|
|
796
|
-
source: buildStepSource(group.update),
|
|
797
|
-
[method]: itemPath,
|
|
798
|
-
expect: {
|
|
799
|
-
status: getExpectedStatus(group.update),
|
|
800
|
-
},
|
|
801
|
-
};
|
|
802
|
-
if (group.update.requiresEtag) {
|
|
803
|
-
step.headers = { "If-Match": `"{{${etagVar}}}"` };
|
|
804
|
-
}
|
|
805
|
-
if (group.update.requestBodySchema) {
|
|
806
|
-
step.json = generateBody(group.update.requestBodySchema);
|
|
807
|
-
}
|
|
808
|
-
tests.push(step);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
// 4. Delete (ARV-368: only if the chain self-captures its id — else the
|
|
812
|
-
// fixture-fallback would DELETE a pre-existing resource → data-loss)
|
|
813
|
-
if (group.delete && canChainMutations) {
|
|
814
|
-
const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
|
|
815
|
-
const etagVar = resourceVar(group.resource, "etag");
|
|
816
|
-
|
|
817
|
-
// If delete requires ETag and update didn't already capture it, add a GET step
|
|
818
|
-
const updateAlreadyCapturedEtag = group.update?.requiresEtag;
|
|
819
|
-
if (group.delete.requiresEtag && group.read && !updateAlreadyCapturedEtag) {
|
|
820
|
-
tests.push({
|
|
821
|
-
name: `Get ETag before delete ${singularizeResource(group.resource)}`,
|
|
822
|
-
source: buildStepSource(group.read),
|
|
823
|
-
GET: itemPath,
|
|
824
|
-
expect: {
|
|
825
|
-
status: getExpectedStatus(group.read),
|
|
826
|
-
headers: { ETag: { capture: etagVar } },
|
|
827
|
-
},
|
|
828
|
-
});
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// T44: cleanup must run even if earlier assertions failed (tainted captures)
|
|
832
|
-
const step: RawStep = {
|
|
833
|
-
name: group.delete.operationId ?? `Delete ${singularizeResource(group.resource)}`,
|
|
834
|
-
source: buildStepSource(group.delete),
|
|
835
|
-
DELETE: itemPath,
|
|
836
|
-
always: true,
|
|
837
|
-
expect: {
|
|
838
|
-
status: getExpectedStatus(group.delete),
|
|
839
|
-
},
|
|
840
|
-
};
|
|
841
|
-
if (group.delete.requiresEtag) {
|
|
842
|
-
step.headers = { "If-Match": `"{{${etagVar}}}"` };
|
|
843
|
-
}
|
|
844
|
-
tests.push(step);
|
|
845
|
-
|
|
846
|
-
// 5. Verify deleted — also always, so we confirm cleanup happened
|
|
847
|
-
if (group.read) {
|
|
848
|
-
tests.push({
|
|
849
|
-
name: `Verify ${singularizeResource(group.resource)} deleted`,
|
|
850
|
-
source: buildStepSource(group.read, 404),
|
|
851
|
-
GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
852
|
-
always: true,
|
|
853
|
-
expect: {
|
|
854
|
-
status: 404,
|
|
855
|
-
},
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// T28: classify by cleanup behavior. A suite that owns a DELETE leaves the API
|
|
861
|
-
// in its starting state (ephemeral); without DELETE it leaves residual data.
|
|
862
|
-
const cleanupTag = group.delete ? "ephemeral" : "persistent-write";
|
|
863
|
-
|
|
864
|
-
const suite: RawSuite = {
|
|
865
|
-
name: `${group.resource}-crud`,
|
|
866
|
-
tags: ["crud", cleanupTag],
|
|
867
|
-
fileStem: `crud-${slugify(group.resource)}`,
|
|
868
|
-
base_url: "{{base_url}}",
|
|
869
|
-
tests,
|
|
870
|
-
};
|
|
871
|
-
|
|
872
|
-
if (suiteHeaders) {
|
|
873
|
-
suite.headers = suiteHeaders;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
return suite;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/** Find unresolved template variables in a suite (excluding known globals, captured vars, and env keys) */
|
|
880
|
-
export function findUnresolvedVars(suite: RawSuite, envKeys?: Set<string>, extraKnown?: Set<string>): string[] {
|
|
881
|
-
const KNOWN = new Set(["base_url", "auth_token", "api_key"]);
|
|
882
|
-
if (envKeys) for (const k of envKeys) KNOWN.add(k);
|
|
883
|
-
if (extraKnown) for (const k of extraKnown) KNOWN.add(k);
|
|
884
|
-
const captured = new Set<string>();
|
|
885
|
-
for (const step of suite.tests) {
|
|
886
|
-
if (step.expect?.body) {
|
|
887
|
-
for (const val of Object.values(step.expect.body)) {
|
|
888
|
-
if (val && typeof val === "object" && "capture" in val) captured.add((val as any).capture);
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
const vars = new Set<string>();
|
|
893
|
-
const scan = (obj: unknown) => {
|
|
894
|
-
if (typeof obj === "string") {
|
|
895
|
-
for (const m of obj.matchAll(/\{\{([^$}][^}]*)\}\}/g)) {
|
|
896
|
-
if (!KNOWN.has(m[1]!) && !captured.has(m[1]!)) vars.add(m[1]!);
|
|
897
|
-
}
|
|
898
|
-
} else if (obj && typeof obj === "object") {
|
|
899
|
-
for (const v of Object.values(obj)) scan(v);
|
|
900
|
-
}
|
|
901
|
-
};
|
|
902
|
-
scan(suite);
|
|
903
|
-
return [...vars];
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
/** Check if a schema has a specific field name (case-insensitive) */
|
|
907
|
-
function schemaHasField(schema: OpenAPIV3.SchemaObject | undefined, ...names: string[]): boolean {
|
|
908
|
-
if (!schema?.properties) return false;
|
|
909
|
-
const keys = Object.keys(schema.properties).map(k => k.toLowerCase());
|
|
910
|
-
return names.some(n => keys.includes(n.toLowerCase()));
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
/** Generate auth suite with register+login consistency */
|
|
914
|
-
export function generateAuthSuite(
|
|
915
|
-
authEndpoints: EndpointInfo[],
|
|
916
|
-
securitySchemes: SecuritySchemeInfo[],
|
|
917
|
-
): RawSuite {
|
|
918
|
-
// Detect register → login pair
|
|
919
|
-
const registerEp = authEndpoints.find(ep =>
|
|
920
|
-
/\/(register|signup)\b/i.test(ep.path) && ep.method.toUpperCase() === "POST"
|
|
921
|
-
);
|
|
922
|
-
const loginEp = authEndpoints.find(ep =>
|
|
923
|
-
ep !== registerEp &&
|
|
924
|
-
/\/(login|signin|auth)\b/i.test(ep.path) &&
|
|
925
|
-
ep.method.toUpperCase() === "POST"
|
|
926
|
-
);
|
|
927
|
-
|
|
928
|
-
const hasCredentialPair = registerEp && loginEp &&
|
|
929
|
-
schemaHasField(registerEp.requestBodySchema, "email", "username") &&
|
|
930
|
-
schemaHasField(registerEp.requestBodySchema, "password") &&
|
|
931
|
-
schemaHasField(loginEp.requestBodySchema, "email", "username") &&
|
|
932
|
-
schemaHasField(loginEp.requestBodySchema, "password");
|
|
933
|
-
|
|
934
|
-
if (hasCredentialPair) {
|
|
935
|
-
return generateConsistentAuthSuite(registerEp, loginEp, authEndpoints, securitySchemes);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
// Fallback: plain auth suite — exclude logout/revoke endpoints from setup suite
|
|
939
|
-
const nonLogoutEndpoints = authEndpoints.filter(ep => !LOGOUT_PATH_RE.test(ep.path));
|
|
940
|
-
const tests = nonLogoutEndpoints.map(ep => generateStep(ep, securitySchemes));
|
|
941
|
-
const headers = getSuiteHeaders(nonLogoutEndpoints, securitySchemes);
|
|
942
|
-
|
|
943
|
-
const suite: RawSuite = {
|
|
944
|
-
name: "auth",
|
|
945
|
-
setup: true,
|
|
946
|
-
tags: ["auth"],
|
|
947
|
-
fileStem: "auth",
|
|
948
|
-
base_url: "{{base_url}}",
|
|
949
|
-
tests,
|
|
950
|
-
};
|
|
951
|
-
|
|
952
|
-
if (headers) {
|
|
953
|
-
suite.headers = headers;
|
|
954
|
-
for (const t of tests) {
|
|
955
|
-
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
956
|
-
delete (t as any).headers;
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
return suite;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
/** Generate auth suite with consistent register → login credentials */
|
|
965
|
-
function generateConsistentAuthSuite(
|
|
966
|
-
registerEp: EndpointInfo,
|
|
967
|
-
loginEp: EndpointInfo,
|
|
968
|
-
allAuthEndpoints: EndpointInfo[],
|
|
969
|
-
securitySchemes: SecuritySchemeInfo[],
|
|
970
|
-
): RawSuite {
|
|
971
|
-
const tests: RawStep[] = [];
|
|
972
|
-
|
|
973
|
-
// Determine credential field: "email" or "username"
|
|
974
|
-
const useEmail = schemaHasField(registerEp.requestBodySchema, "email");
|
|
975
|
-
const credField = useEmail ? "email" : "username";
|
|
976
|
-
const credValue = useEmail ? "test_{{$timestamp}}@test.com" : "testuser_{{$timestamp}}";
|
|
977
|
-
|
|
978
|
-
// 0. Set shared credentials
|
|
979
|
-
const setStep: RawStep = {
|
|
980
|
-
name: "Set test credentials",
|
|
981
|
-
set: {
|
|
982
|
-
[`test_${credField}`]: credValue,
|
|
983
|
-
test_password: "TestPass123!",
|
|
984
|
-
},
|
|
985
|
-
expect: {},
|
|
986
|
-
} as RawStep;
|
|
987
|
-
tests.push(setStep);
|
|
988
|
-
|
|
989
|
-
// 1. Register step — replace credential fields with shared vars
|
|
990
|
-
const registerStep = generateStep(registerEp, securitySchemes);
|
|
991
|
-
if (registerStep.json && typeof registerStep.json === "object") {
|
|
992
|
-
const json = registerStep.json as Record<string, unknown>;
|
|
993
|
-
if (credField in json) json[credField] = `{{test_${credField}}}`;
|
|
994
|
-
if ("password" in json) json.password = "{{test_password}}";
|
|
995
|
-
}
|
|
996
|
-
tests.push(registerStep);
|
|
997
|
-
|
|
998
|
-
// 2. Login step — reuse same credentials + capture token
|
|
999
|
-
const loginStep = generateStep(loginEp, securitySchemes);
|
|
1000
|
-
if (loginStep.json && typeof loginStep.json === "object") {
|
|
1001
|
-
const json = loginStep.json as Record<string, unknown>;
|
|
1002
|
-
if (credField in json) json[credField] = `{{test_${credField}}}`;
|
|
1003
|
-
if ("password" in json) json.password = "{{test_password}}";
|
|
1004
|
-
}
|
|
1005
|
-
// Try to capture auth token from login response
|
|
1006
|
-
const loginSchema = getSuccessSchema(loginEp);
|
|
1007
|
-
if (loginSchema?.properties) {
|
|
1008
|
-
const tokenField = Object.keys(loginSchema.properties).find(k =>
|
|
1009
|
-
/token|access_token|accessToken|jwt/i.test(k)
|
|
1010
|
-
);
|
|
1011
|
-
if (tokenField) {
|
|
1012
|
-
// Determine the capture variable name based on the login endpoint's security scheme
|
|
1013
|
-
const loginScheme = loginEp.security.length > 0
|
|
1014
|
-
? securitySchemes.find(s => s.name === loginEp.security[0])
|
|
1015
|
-
: undefined;
|
|
1016
|
-
const captureVar = loginScheme ? schemeVarName(loginScheme, securitySchemes) : "auth_token";
|
|
1017
|
-
if (!loginStep.expect.body) loginStep.expect.body = {};
|
|
1018
|
-
loginStep.expect.body[tokenField] = { capture: captureVar };
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
tests.push(loginStep);
|
|
1022
|
-
|
|
1023
|
-
// 3. Any remaining auth endpoints (not register/login, not logout)
|
|
1024
|
-
// Logout/revoke endpoints must NOT be in a setup suite — they invalidate the token
|
|
1025
|
-
const others = allAuthEndpoints.filter(ep =>
|
|
1026
|
-
ep !== registerEp && ep !== loginEp && !LOGOUT_PATH_RE.test(ep.path)
|
|
1027
|
-
);
|
|
1028
|
-
for (const ep of others) {
|
|
1029
|
-
tests.push(generateStep(ep, securitySchemes));
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
return {
|
|
1033
|
-
name: "auth",
|
|
1034
|
-
setup: true,
|
|
1035
|
-
tags: ["auth"],
|
|
1036
|
-
fileStem: "auth",
|
|
1037
|
-
base_url: "{{base_url}}",
|
|
1038
|
-
tests,
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
/** Generate 1-2 minimal tests for quick connectivity and auth validation */
|
|
1043
|
-
function generateSanitySuite(opts: {
|
|
1044
|
-
authEndpoints: EndpointInfo[];
|
|
1045
|
-
nonAuthGetEndpoints: EndpointInfo[];
|
|
1046
|
-
securitySchemes: SecuritySchemeInfo[];
|
|
1047
|
-
}): RawSuite | null {
|
|
1048
|
-
const { authEndpoints, nonAuthGetEndpoints, securitySchemes } = opts;
|
|
1049
|
-
const tests: RawStep[] = [];
|
|
1050
|
-
|
|
1051
|
-
// Priority 1: auth login/token endpoint
|
|
1052
|
-
if (authEndpoints.length > 0) {
|
|
1053
|
-
const loginEp =
|
|
1054
|
-
authEndpoints.find(ep => /\/(login|signin|token)\b/i.test(ep.path) && ep.method.toUpperCase() === "POST") ??
|
|
1055
|
-
authEndpoints[0]!;
|
|
1056
|
-
tests.push(generateStep(loginEp, securitySchemes));
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
// Priority 2: healthcheck or first simple GET with no path params
|
|
1060
|
-
const healthEp = selectHealthcheckEndpoint(nonAuthGetEndpoints);
|
|
1061
|
-
if (healthEp) {
|
|
1062
|
-
tests.push(generateStep(healthEp, securitySchemes));
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
if (tests.length === 0) return null;
|
|
1066
|
-
|
|
1067
|
-
return {
|
|
1068
|
-
name: "sanity",
|
|
1069
|
-
tags: ["sanity"],
|
|
1070
|
-
fileStem: "sanity",
|
|
1071
|
-
base_url: "{{base_url}}",
|
|
1072
|
-
tests: tests.slice(0, 2),
|
|
1073
|
-
};
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
/** Main entry point: generate all suites from endpoints */
|
|
1077
|
-
export function generateSuites(opts: {
|
|
1078
|
-
endpoints: EndpointInfo[];
|
|
1079
|
-
securitySchemes: SecuritySchemeInfo[];
|
|
1080
|
-
/** Path to OpenAPI spec, recorded in suite-level provenance. */
|
|
1081
|
-
specPath?: string;
|
|
1082
|
-
/** When true, deprecated endpoints are included instead of filtered out. */
|
|
1083
|
-
includeDeprecated?: boolean;
|
|
1084
|
-
/** ARV-212 (R13/F16): inject `Authorization: Bearer {{<varName>}}` at the
|
|
1085
|
-
* suite level when the spec declares no securitySchemes but the workspace
|
|
1086
|
-
* .env.yaml carries this auth-token variable. Lets generated suites talk
|
|
1087
|
-
* to bare-spec APIs (GitHub) without going unauth. */
|
|
1088
|
-
defaultAuthVar?: string;
|
|
1089
|
-
}): RawSuite[] {
|
|
1090
|
-
const { endpoints, securitySchemes, specPath, includeDeprecated, defaultAuthVar } = opts;
|
|
1091
|
-
_suiteDefaultAuthVar = defaultAuthVar ?? null;
|
|
1092
|
-
_ambiguousPathParams = computeAmbiguousPathParams(endpoints);
|
|
1093
|
-
|
|
1094
|
-
// Filter deprecated unless caller opted in. The list of skipped paths is
|
|
1095
|
-
// exposed separately via `getSkippedDeprecated` for stdout reporting.
|
|
1096
|
-
const active = includeDeprecated ? endpoints : endpoints.filter(ep => !ep.deprecated);
|
|
1097
|
-
|
|
1098
|
-
// Separate auth endpoints
|
|
1099
|
-
const authEndpoints = active.filter(isAuthEndpoint);
|
|
1100
|
-
const nonAuth = active.filter(ep => !isAuthEndpoint(ep));
|
|
1101
|
-
|
|
1102
|
-
// 1. Detect CRUD groups
|
|
1103
|
-
const crudGroups = detectCrudGroups(nonAuth);
|
|
1104
|
-
|
|
1105
|
-
// Collect endpoints consumed by CRUD groups
|
|
1106
|
-
const crudEndpointKeys = new Set<string>();
|
|
1107
|
-
for (const g of crudGroups) {
|
|
1108
|
-
if (g.create) crudEndpointKeys.add(`${g.create.method.toUpperCase()} ${g.create.path}`);
|
|
1109
|
-
if (g.list) crudEndpointKeys.add(`${g.list.method.toUpperCase()} ${g.list.path}`);
|
|
1110
|
-
if (g.read) crudEndpointKeys.add(`${g.read.method.toUpperCase()} ${g.read.path}`);
|
|
1111
|
-
if (g.update) crudEndpointKeys.add(`${g.update.method.toUpperCase()} ${g.update.path}`);
|
|
1112
|
-
if (g.delete) crudEndpointKeys.add(`${g.delete.method.toUpperCase()} ${g.delete.path}`);
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
// Remaining endpoints (not in any CRUD group, not auth)
|
|
1116
|
-
const remaining = nonAuth.filter(ep => !crudEndpointKeys.has(`${ep.method.toUpperCase()} ${ep.path}`));
|
|
1117
|
-
|
|
1118
|
-
const suites: RawSuite[] = [];
|
|
1119
|
-
|
|
1120
|
-
// 2. Group remaining by tag → smoke + smoke-unsafe
|
|
1121
|
-
const byTag = groupEndpointsByTag(remaining);
|
|
1122
|
-
|
|
1123
|
-
for (const [tag, tagEndpoints] of byTag) {
|
|
1124
|
-
const tagSlug = slugify(tag) || "api";
|
|
1125
|
-
|
|
1126
|
-
// GET endpoints → split into paramless (regular smoke) and path-param (negative+positive smoke)
|
|
1127
|
-
const getEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() === "GET");
|
|
1128
|
-
const paramlessGets = getEndpoints.filter(ep => !endpointHasPathParams(ep));
|
|
1129
|
-
const pathParamGets = getEndpoints.filter(ep => endpointHasPathParams(ep));
|
|
1130
|
-
|
|
1131
|
-
// Positive smoke: paramless GETs (no env needed) + path-param GETs
|
|
1132
|
-
// (with skip_if guards). TASK-240 — unified naming convention:
|
|
1133
|
-
// always emit `smoke-<tag>-positive.yaml`, never the bare
|
|
1134
|
-
// `smoke-<tag>.yaml`, so file listings don't have to explain why a
|
|
1135
|
-
// tag has only `-negative` (e.g. a vendor-specific tag) or why two
|
|
1136
|
-
// siblings differ in suffix shape.
|
|
1137
|
-
const positiveTests = [
|
|
1138
|
-
...paramlessGets.map(ep => {
|
|
1139
|
-
const step = generateStep(ep, securitySchemes);
|
|
1140
|
-
const seededPath = convertPathWithSeeds(ep.path, ep);
|
|
1141
|
-
(step as any)[ep.method.toUpperCase()] = seededPath;
|
|
1142
|
-
return step;
|
|
1143
|
-
}),
|
|
1144
|
-
...pathParamGets.map(ep => {
|
|
1145
|
-
const step = generateStep(ep, securitySchemes);
|
|
1146
|
-
// Path stays as {{param}} so user-provided env values flow in.
|
|
1147
|
-
// skip_if guards an unset path-param without skipping paramless
|
|
1148
|
-
// siblings that don't need a fixture.
|
|
1149
|
-
const firstPathParam = ep.parameters.find(p => p.in === "path");
|
|
1150
|
-
if (firstPathParam) {
|
|
1151
|
-
step.skip_if = `{{${firstPathParam.name}}} ==`;
|
|
1152
|
-
}
|
|
1153
|
-
return step;
|
|
1154
|
-
}),
|
|
1155
|
-
];
|
|
1156
|
-
|
|
1157
|
-
if (positiveTests.length > 0) {
|
|
1158
|
-
const positiveEndpoints = [...paramlessGets, ...pathParamGets];
|
|
1159
|
-
const headers = getSuiteHeaders(positiveEndpoints, securitySchemes);
|
|
1160
|
-
// needs-id only when at least one test depends on a path-param
|
|
1161
|
-
// fixture — coverage downgrades these suites when env is empty.
|
|
1162
|
-
const tags = pathParamGets.length > 0
|
|
1163
|
-
? ["smoke", "positive", "needs-id"]
|
|
1164
|
-
: ["smoke", "positive"];
|
|
1165
|
-
|
|
1166
|
-
const suite: RawSuite = {
|
|
1167
|
-
name: `${tagSlug}-smoke-positive`,
|
|
1168
|
-
tags,
|
|
1169
|
-
fileStem: `smoke-${tagSlug}-positive`,
|
|
1170
|
-
base_url: "{{base_url}}",
|
|
1171
|
-
tests: positiveTests,
|
|
1172
|
-
};
|
|
1173
|
-
|
|
1174
|
-
if (headers) {
|
|
1175
|
-
suite.headers = headers;
|
|
1176
|
-
for (const t of positiveTests) {
|
|
1177
|
-
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
1178
|
-
delete (t as any).headers;
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
suites.push(suite);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
// Negative smoke: path-param GETs with guaranteed-bad IDs, expect 400/404/422
|
|
1187
|
-
if (pathParamGets.length > 0) {
|
|
1188
|
-
const tests = pathParamGets.map(ep => {
|
|
1189
|
-
const step = generateStep(ep, securitySchemes);
|
|
1190
|
-
(step as any)[ep.method.toUpperCase()] = convertPathWithBadIds(ep.path, ep);
|
|
1191
|
-
// Negative path: resource doesn't exist. Drop body assertions (response shape varies).
|
|
1192
|
-
step.expect = { status: [400, 404, 422] };
|
|
1193
|
-
return step;
|
|
1194
|
-
});
|
|
1195
|
-
const headers = getSuiteHeaders(pathParamGets, securitySchemes);
|
|
1196
|
-
|
|
1197
|
-
const suite: RawSuite = {
|
|
1198
|
-
name: `${tagSlug}-smoke-negative`,
|
|
1199
|
-
tags: ["smoke", "negative"],
|
|
1200
|
-
fileStem: `smoke-${tagSlug}-negative`,
|
|
1201
|
-
base_url: "{{base_url}}",
|
|
1202
|
-
tests,
|
|
1203
|
-
};
|
|
1204
|
-
|
|
1205
|
-
if (headers) {
|
|
1206
|
-
suite.headers = headers;
|
|
1207
|
-
for (const t of tests) {
|
|
1208
|
-
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
1209
|
-
delete (t as any).headers;
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
suites.push(suite);
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
// Non-GET endpoints: split reset/system endpoints out of smoke-unsafe
|
|
1218
|
-
const nonGetEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() !== "GET");
|
|
1219
|
-
const resetEndpoints = nonGetEndpoints.filter(ep => RESET_PATH_RE.test(ep.path));
|
|
1220
|
-
const unsafeEndpoints = nonGetEndpoints.filter(ep => !RESET_PATH_RE.test(ep.path));
|
|
1221
|
-
|
|
1222
|
-
// Reset/system endpoints → [system, reset] suite (never run as part of smoke)
|
|
1223
|
-
if (resetEndpoints.length > 0) {
|
|
1224
|
-
const tests = resetEndpoints.map(ep => generateStep(ep, securitySchemes));
|
|
1225
|
-
const headers = getSuiteHeaders(resetEndpoints, securitySchemes);
|
|
1226
|
-
|
|
1227
|
-
const suite: RawSuite = {
|
|
1228
|
-
name: `${tagSlug}-system`,
|
|
1229
|
-
tags: ["system", "reset"],
|
|
1230
|
-
fileStem: `system-${tagSlug}`,
|
|
1231
|
-
base_url: "{{base_url}}",
|
|
1232
|
-
tests,
|
|
1233
|
-
};
|
|
1234
|
-
|
|
1235
|
-
if (headers) {
|
|
1236
|
-
suite.headers = headers;
|
|
1237
|
-
for (const t of tests) {
|
|
1238
|
-
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
1239
|
-
delete (t as any).headers;
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
suites.push(suite);
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
// Remaining non-GET endpoints → smoke-unsafe suite
|
|
1248
|
-
if (unsafeEndpoints.length > 0) {
|
|
1249
|
-
const tests = unsafeEndpoints.map(ep => generateStep(ep, securitySchemes));
|
|
1250
|
-
const headers = getSuiteHeaders(unsafeEndpoints, securitySchemes);
|
|
1251
|
-
|
|
1252
|
-
const suite: RawSuite = {
|
|
1253
|
-
name: `${tagSlug}-smoke-unsafe`,
|
|
1254
|
-
tags: ["smoke", "unsafe"],
|
|
1255
|
-
fileStem: `smoke-${tagSlug}-unsafe`,
|
|
1256
|
-
base_url: "{{base_url}}",
|
|
1257
|
-
tests,
|
|
1258
|
-
};
|
|
1259
|
-
|
|
1260
|
-
if (headers) {
|
|
1261
|
-
suite.headers = headers;
|
|
1262
|
-
for (const t of tests) {
|
|
1263
|
-
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
1264
|
-
delete (t as any).headers;
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
suites.push(suite);
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
// 3. CRUD suites
|
|
1274
|
-
for (const group of crudGroups) {
|
|
1275
|
-
suites.push(generateCrudSuite(group, securitySchemes));
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
// 4. Auth suite (separate — requires real credentials)
|
|
1279
|
-
if (authEndpoints.length > 0) {
|
|
1280
|
-
const suite = generateAuthSuite(authEndpoints, securitySchemes);
|
|
1281
|
-
suites.push(suite);
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
// 5. Sanity suite (prepend — 1-2 tests for quick connectivity/auth check)
|
|
1285
|
-
const nonAuthGetEndpoints = nonAuth.filter(ep => ep.method.toUpperCase() === "GET");
|
|
1286
|
-
const sanitySuite = generateSanitySuite({ authEndpoints, nonAuthGetEndpoints, securitySchemes });
|
|
1287
|
-
|
|
1288
|
-
const allSuites = sanitySuite ? [sanitySuite, ...suites] : suites;
|
|
1289
|
-
|
|
1290
|
-
// Stamp suite-level provenance when a spec path is known.
|
|
1291
|
-
const suiteSrc = buildOpenApiSuiteSource(specPath);
|
|
1292
|
-
if (suiteSrc) {
|
|
1293
|
-
for (const s of allSuites) {
|
|
1294
|
-
s.source = suiteSrc;
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
_suiteDefaultAuthVar = null; // ARV-212
|
|
1299
|
-
_ambiguousPathParams = new Set(); // ARV-369
|
|
1300
|
-
return allSuites;
|
|
1301
|
-
}
|