@kirrosh/zond 0.22.0 → 0.23.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 +648 -0
- package/README.md +58 -6
- package/package.json +9 -6
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -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 +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -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 +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +178 -7
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -46
- package/src/cli/commands/init/bootstrap.ts +30 -1
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +56 -3
- package/src/cli/commands/init/templates/agents.md +65 -61
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +592 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +842 -53
- 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 +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +198 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -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 +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -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 +61 -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 +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +151 -11
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -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 +42 -16
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +445 -19
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +37 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +419 -111
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -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 +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -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 +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -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 +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -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 +284 -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 +58 -1
- 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 +264 -20
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +75 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +89 -17
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -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 +415 -16
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -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 +128 -0
- package/src/db/queries/runs.ts +235 -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 +72 -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
|
@@ -1,13 +1,53 @@
|
|
|
1
1
|
import type { OpenAPIV3 } from "openapi-types";
|
|
2
2
|
import type { EndpointInfo, SecuritySchemeInfo, CrudGroup } from "./types.ts";
|
|
3
3
|
import type { RawSuite, RawStep } from "./serializer.ts";
|
|
4
|
+
import type { SourceMetadata } from "../parser/types.ts";
|
|
4
5
|
import { generateFromSchema, generateMultipartFromSchema } from "./data-factory.ts";
|
|
5
6
|
import { groupEndpointsByTag } from "./chunker.ts";
|
|
7
|
+
import { getAuthHeaders as sharedGetAuthHeaders } from "../probe/shared.ts";
|
|
8
|
+
import { flattenToFormFields } from "../runner/form-encode.ts";
|
|
6
9
|
|
|
7
10
|
// ──────────────────────────────────────────────
|
|
8
11
|
// Helpers
|
|
9
12
|
// ──────────────────────────────────────────────
|
|
10
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
|
+
|
|
11
51
|
/** Convert OpenAPI path params {param} to test interpolation {{param}} */
|
|
12
52
|
function convertPath(path: string): string {
|
|
13
53
|
return path.replace(/\{([^}]+)\}/g, "{{$1}}");
|
|
@@ -73,11 +113,32 @@ function selectHealthcheckEndpoint(gets: EndpointInfo[]): EndpointInfo | undefin
|
|
|
73
113
|
);
|
|
74
114
|
}
|
|
75
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Pick the success status the test should assert.
|
|
118
|
+
*
|
|
119
|
+
* Order:
|
|
120
|
+
* 1. First 2xx declared in the spec (most authoritative).
|
|
121
|
+
* 2. Method-aware default when the spec lists only non-2xx responses or none
|
|
122
|
+
* at all (many OpenAPI specs is silent for several mutating endpoints — the
|
|
123
|
+
* actual runtime returns 201/204, while the old default of 200 caused
|
|
124
|
+
* tests to fail at runtime). We never assert a 4xx/5xx as the success
|
|
125
|
+
* status — that would generate guaranteed-failing tests.
|
|
126
|
+
*/
|
|
76
127
|
function getExpectedStatus(ep: EndpointInfo): number {
|
|
77
128
|
const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
|
|
78
129
|
if (success) return success.statusCode;
|
|
79
|
-
|
|
80
|
-
|
|
130
|
+
return defaultStatusByMethod(ep.method);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function defaultStatusByMethod(method: string): number {
|
|
134
|
+
switch (method.toUpperCase()) {
|
|
135
|
+
case "POST":
|
|
136
|
+
return 201;
|
|
137
|
+
case "DELETE":
|
|
138
|
+
return 204;
|
|
139
|
+
default:
|
|
140
|
+
return 200;
|
|
141
|
+
}
|
|
81
142
|
}
|
|
82
143
|
|
|
83
144
|
function getSuccessSchema(ep: EndpointInfo): OpenAPIV3.SchemaObject | undefined {
|
|
@@ -105,7 +166,7 @@ function getBodyAssertions(ep: EndpointInfo): Record<string, Record<string, stri
|
|
|
105
166
|
}
|
|
106
167
|
|
|
107
168
|
/** Derive a variable name for a security scheme's token */
|
|
108
|
-
function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
|
|
169
|
+
export function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
|
|
109
170
|
// Count how many bearer-like schemes exist
|
|
110
171
|
const bearerSchemes = allSchemes.filter(s =>
|
|
111
172
|
(s.type === "http" && (s.scheme === "bearer" || !s.scheme)) ||
|
|
@@ -122,29 +183,7 @@ function getAuthHeaders(
|
|
|
122
183
|
ep: EndpointInfo,
|
|
123
184
|
schemes: SecuritySchemeInfo[],
|
|
124
185
|
): Record<string, string> | undefined {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
for (const secName of ep.security) {
|
|
128
|
-
const scheme = schemes.find(s => s.name === secName);
|
|
129
|
-
if (!scheme) continue;
|
|
130
|
-
|
|
131
|
-
if (scheme.type === "http") {
|
|
132
|
-
if (scheme.scheme === "bearer" || !scheme.scheme) {
|
|
133
|
-
return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
|
|
134
|
-
}
|
|
135
|
-
if (scheme.scheme === "basic") {
|
|
136
|
-
return { Authorization: `Basic {{${schemeVarName(scheme, schemes)}}}` };
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
|
|
140
|
-
if (scheme.apiKeyName === "Authorization") {
|
|
141
|
-
return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
|
|
142
|
-
}
|
|
143
|
-
return { [scheme.apiKeyName]: "{{api_key}}" };
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return undefined;
|
|
186
|
+
return sharedGetAuthHeaders(ep, schemes, s => schemeVarName(s, schemes));
|
|
148
187
|
}
|
|
149
188
|
|
|
150
189
|
function getRequiredQueryParams(ep: EndpointInfo): Record<string, string> | undefined {
|
|
@@ -172,23 +211,88 @@ function getSuiteHeaders(
|
|
|
172
211
|
|
|
173
212
|
const headerSets = endpoints.map(ep => getAuthHeaders(ep, schemes));
|
|
174
213
|
const first = headerSets[0];
|
|
175
|
-
if (!first)
|
|
214
|
+
if (!first) {
|
|
215
|
+
// ARV-212 (R13/F16): spec has no securitySchemes (GitHub publishes its
|
|
216
|
+
// OpenAPI this way) so per-endpoint auth-header derivation returns
|
|
217
|
+
// undefined for every step. When the API workspace nonetheless wires
|
|
218
|
+
// `auth_token` end-to-end (ARV-201 seeds it in .env.yaml on bare specs,
|
|
219
|
+
// and zond request / runner auto-attach Authorization: Bearer when
|
|
220
|
+
// auth_token is present), generated suites should not silently go
|
|
221
|
+
// unauth — that bricks them on the first rate-limited 60 requests.
|
|
222
|
+
// Fall back to a generic Bearer header at the suite level. The header
|
|
223
|
+
// is harmless when .secrets.yaml.auth_token is empty (zond runner
|
|
224
|
+
// still substitutes `{{auth_token}}` to an empty string, just like
|
|
225
|
+
// before; the server then 401s — same outcome as today).
|
|
226
|
+
if (schemes.length === 0 && _suiteDefaultAuthVar !== null) {
|
|
227
|
+
return { Authorization: `Bearer {{${_suiteDefaultAuthVar}}}` };
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
176
231
|
|
|
177
232
|
const firstJson = JSON.stringify(first);
|
|
178
233
|
const allSame = headerSets.every(h => JSON.stringify(h) === firstJson);
|
|
179
234
|
return allSame ? first : undefined;
|
|
180
235
|
}
|
|
181
236
|
|
|
182
|
-
|
|
183
|
-
|
|
237
|
+
// ARV-212 (R13/F16): generator-level "the caller wired auth_token in
|
|
238
|
+
// .env.yaml even though the spec has no securitySchemes" hint. Set at the
|
|
239
|
+
// top of generateSuites and consulted by getSuiteHeaders / generateCrudSuite
|
|
240
|
+
// / generateSanitySuite. Module-scoped to avoid threading through ~7 call
|
|
241
|
+
// sites. Always reset to null at the end of generateSuites so the helper
|
|
242
|
+
// stays stateless from the caller's perspective.
|
|
243
|
+
let _suiteDefaultAuthVar: string | null = null;
|
|
244
|
+
|
|
245
|
+
/** Common id-like field names looked up after `id` itself.
|
|
246
|
+
* TASK-139: many real-world APIs return `slug`, `uuid`, `version`, `key`,
|
|
247
|
+
* or `name` instead of an `id` field on create responses. Without these,
|
|
248
|
+
* CRUD chains fall back to capturing `"id"` from a body that doesn't have
|
|
249
|
+
* one, breaking the `{id}` substitution in follow-up reads. */
|
|
250
|
+
const ID_LIKE_NAMES = ["slug", "uuid", "key", "version", "name"];
|
|
251
|
+
|
|
252
|
+
/** Find the best field to capture from POST response (for CRUD chains).
|
|
253
|
+
*
|
|
254
|
+
* Priority:
|
|
255
|
+
* 1. Field whose name matches the path-param (e.g. `{rule_id}` → `rule_id`
|
|
256
|
+
* or `{slug}` → `slug`). The path-param name is the strongest hint —
|
|
257
|
+
* whatever the response calls "the id of this resource" is what gets
|
|
258
|
+
* interpolated back into the read/update/delete URLs.
|
|
259
|
+
* 2. `id` (most common case).
|
|
260
|
+
* 3. Conventional id-like names: `slug`, `uuid`, `key`, `version`, `name`
|
|
261
|
+
* — but only if they are typed as a string (avoids capturing a `name`
|
|
262
|
+
* object on resources that nest metadata).
|
|
263
|
+
* 4. Any field with `type: integer` or `format: uuid`.
|
|
264
|
+
* 5. Fallback: `"id"` (the YAML capture will simply be empty if absent —
|
|
265
|
+
* the runner already handles this gracefully).
|
|
266
|
+
*/
|
|
267
|
+
function getCaptureField(ep: EndpointInfo, idParam?: string): string {
|
|
184
268
|
const schema = getSuccessSchema(ep);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
269
|
+
const props = schema?.properties;
|
|
270
|
+
if (!props) return "id";
|
|
271
|
+
|
|
272
|
+
// 1. Path-param name match.
|
|
273
|
+
if (idParam) {
|
|
274
|
+
if (idParam in props) return idParam;
|
|
275
|
+
// Also try the conventional `<resource>_id` ↔ `id` swap.
|
|
276
|
+
if (idParam.endsWith("_id") && "id" in props) return "id";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 2. Plain `id`.
|
|
280
|
+
if ("id" in props) return "id";
|
|
281
|
+
|
|
282
|
+
// 3. Conventional id-like names (string-typed only).
|
|
283
|
+
for (const candidate of ID_LIKE_NAMES) {
|
|
284
|
+
if (candidate in props) {
|
|
285
|
+
const s = props[candidate] as OpenAPIV3.SchemaObject;
|
|
286
|
+
if (s.type === "string") return candidate;
|
|
190
287
|
}
|
|
191
288
|
}
|
|
289
|
+
|
|
290
|
+
// 4. Any integer or uuid-shaped field.
|
|
291
|
+
for (const [name, propSchema] of Object.entries(props)) {
|
|
292
|
+
const s = propSchema as OpenAPIV3.SchemaObject;
|
|
293
|
+
if (s.type === "integer" || s.format === "uuid") return name;
|
|
294
|
+
}
|
|
295
|
+
|
|
192
296
|
return "id";
|
|
193
297
|
}
|
|
194
298
|
|
|
@@ -200,6 +304,46 @@ function isAuthEndpoint(ep: EndpointInfo): boolean {
|
|
|
200
304
|
return false;
|
|
201
305
|
}
|
|
202
306
|
|
|
307
|
+
// ──────────────────────────────────────────────
|
|
308
|
+
// Provenance helpers
|
|
309
|
+
// ──────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
function escapeJsonPointerSegment(s: string): string {
|
|
312
|
+
return s.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function pickPrimaryStatus(status: number | number[]): number {
|
|
316
|
+
return Array.isArray(status) ? (status[0] ?? 200) : status;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Build step-level provenance for an endpoint + chosen response status. */
|
|
320
|
+
export function buildStepSource(
|
|
321
|
+
ep: EndpointInfo,
|
|
322
|
+
statusOverride?: number | number[],
|
|
323
|
+
): SourceMetadata {
|
|
324
|
+
const method = ep.method.toUpperCase();
|
|
325
|
+
const status = statusOverride ?? getExpectedStatus(ep);
|
|
326
|
+
const primary = pickPrimaryStatus(status);
|
|
327
|
+
const responseBranch = Array.isArray(status) ? status.map(String).join("|") : String(status);
|
|
328
|
+
const escapedPath = escapeJsonPointerSegment(ep.path);
|
|
329
|
+
return {
|
|
330
|
+
endpoint: `${method} ${ep.path}`,
|
|
331
|
+
response_branch: responseBranch,
|
|
332
|
+
schema_pointer: `#/paths/${escapedPath}/${method.toLowerCase()}/responses/${primary}`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Build suite-level provenance for an openapi-generated suite. */
|
|
337
|
+
export function buildOpenApiSuiteSource(specPath?: string): SourceMetadata | undefined {
|
|
338
|
+
if (!specPath) return undefined;
|
|
339
|
+
return {
|
|
340
|
+
type: "openapi-generated",
|
|
341
|
+
spec: specPath,
|
|
342
|
+
generator: "zond-generate",
|
|
343
|
+
generated_at: new Date().toISOString(),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
203
347
|
// ──────────────────────────────────────────────
|
|
204
348
|
// Public API
|
|
205
349
|
// ──────────────────────────────────────────────
|
|
@@ -215,6 +359,7 @@ export function generateStep(
|
|
|
215
359
|
|
|
216
360
|
const step: RawStep = {
|
|
217
361
|
name,
|
|
362
|
+
source: buildStepSource(ep),
|
|
218
363
|
[method]: path,
|
|
219
364
|
expect: {
|
|
220
365
|
status: getExpectedStatus(ep),
|
|
@@ -229,6 +374,12 @@ export function generateStep(
|
|
|
229
374
|
if (["POST", "PUT", "PATCH"].includes(method) && ep.requestBodySchema) {
|
|
230
375
|
if (ep.requestBodyContentType === "multipart/form-data") {
|
|
231
376
|
step.multipart = generateMultipartFromSchema(ep.requestBodySchema);
|
|
377
|
+
} else if (ep.requestBodyContentType === "application/x-www-form-urlencoded") {
|
|
378
|
+
// ARV-149: form-encoded endpoints (Stripe v1 et al.) — emit `form:` so
|
|
379
|
+
// the runner posts URL-encoded bodies with bracket notation. Without
|
|
380
|
+
// this, generate baked `json:` blocks and every POST 400'd with
|
|
381
|
+
// "wrong content type".
|
|
382
|
+
step.form = flattenToFormFields(generateFromSchema(ep.requestBodySchema));
|
|
232
383
|
} else {
|
|
233
384
|
step.json = generateFromSchema(ep.requestBodySchema);
|
|
234
385
|
}
|
|
@@ -247,33 +398,163 @@ export function generateStep(
|
|
|
247
398
|
return step;
|
|
248
399
|
}
|
|
249
400
|
|
|
250
|
-
/**
|
|
401
|
+
/** Strip a single trailing slash for comparison purposes. We never rewrite
|
|
402
|
+
* endpoint paths in the spec — we just normalise the matching regex so
|
|
403
|
+
* `POST /alerts/` + `GET /alerts/{id}/` lines up the same as the no-slash
|
|
404
|
+
* variant. */
|
|
405
|
+
function stripTrailingSlash(p: string): string {
|
|
406
|
+
return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Per-resource diagnostic record used by `zond generate --explain`.
|
|
410
|
+
* Captures every POST candidate the detector considered and the verdict
|
|
411
|
+
* with a human reason — so users can see "I have a CRUD-looking pair, why
|
|
412
|
+
* didn't generate emit a chain?" without grepping the spec. */
|
|
413
|
+
export interface CrudDetectionDiagnostic {
|
|
414
|
+
resource: string;
|
|
415
|
+
basePath: string;
|
|
416
|
+
postPath: string;
|
|
417
|
+
hasGetById: boolean;
|
|
418
|
+
hasUpdate: boolean;
|
|
419
|
+
hasDelete: boolean;
|
|
420
|
+
hasList: boolean;
|
|
421
|
+
verdict: "chain" | "skipped";
|
|
422
|
+
reason: string;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface DetectCrudResult {
|
|
426
|
+
groups: CrudGroup[];
|
|
427
|
+
diagnostics: CrudDetectionDiagnostic[];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Detect CRUD groups from a list of endpoints.
|
|
431
|
+
*
|
|
432
|
+
* Match logic (TASK-139):
|
|
433
|
+
* - basePath = POST endpoint's path with any trailing slash trimmed.
|
|
434
|
+
* - item path = `<basePath>/{param}` with optional trailing slash.
|
|
435
|
+
* This catches common SaaS-style `POST /alert-rules/` + `GET /alert-rules/{id}/`
|
|
436
|
+
* pairs that previously fell through because the regex required the same
|
|
437
|
+
* slash form on both. */
|
|
251
438
|
export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
|
|
439
|
+
return detectCrudGroupsWithDiagnostics(endpoints).groups;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function detectCrudGroupsWithDiagnostics(
|
|
443
|
+
endpoints: EndpointInfo[],
|
|
444
|
+
): DetectCrudResult {
|
|
252
445
|
const groups: CrudGroup[] = [];
|
|
253
|
-
const
|
|
446
|
+
const diagnostics: CrudDetectionDiagnostic[] = [];
|
|
447
|
+
const postEndpoints = endpoints.filter(
|
|
448
|
+
ep => ep.method.toUpperCase() === "POST" && !ep.deprecated,
|
|
449
|
+
);
|
|
254
450
|
|
|
255
451
|
for (const createEp of postEndpoints) {
|
|
256
|
-
const basePath = createEp.path;
|
|
452
|
+
const basePath = stripTrailingSlash(createEp.path);
|
|
453
|
+
const resource = basePath.split("/").filter(Boolean).pop() ?? "resource";
|
|
454
|
+
|
|
455
|
+
// Match `<basePath>/{param}` with optional trailing slash. Tolerates
|
|
456
|
+
// both `POST /alerts/` + `GET /alerts/{id}` and `POST /alerts` +
|
|
457
|
+
// `GET /alerts/{id}/`, which some real-world specs mix.
|
|
458
|
+
const itemPattern = new RegExp(`^${escapeRegex(basePath)}/\\{([^}]+)\\}/?$`);
|
|
459
|
+
const itemEndpoints = endpoints.filter(
|
|
460
|
+
ep => !ep.deprecated && itemPattern.test(ep.path),
|
|
461
|
+
);
|
|
257
462
|
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
463
|
+
// Fallback for "subdomain"/nested-item routing (common SaaS-style):
|
|
464
|
+
// create lives under one root (`/api/0/organizations/{org}/teams/`)
|
|
465
|
+
// but item-path lives under another (`/api/0/teams/{org}/{team}/`).
|
|
466
|
+
// The strict basePath/{id} regex misses these. Match instead by:
|
|
467
|
+
// 1. shared OpenAPI tag with the create operation,
|
|
468
|
+
// 2. terminal {param} matching the singular form of the resource
|
|
469
|
+
// (`{team}` / `{team_id}` / `{team_id_or_slug}`).
|
|
470
|
+
let resolvedItemEndpoints = itemEndpoints;
|
|
471
|
+
if (resolvedItemEndpoints.length === 0) {
|
|
472
|
+
const singular = singularizeResource(resource).toLowerCase();
|
|
473
|
+
const itemTerminalRe = /\{([^}]+)\}\/?$/;
|
|
474
|
+
const matchesResourceParam = (p: string) => {
|
|
475
|
+
const m = p.match(itemTerminalRe);
|
|
476
|
+
if (!m) return false;
|
|
477
|
+
const param = m[1]!.toLowerCase();
|
|
478
|
+
return (
|
|
479
|
+
param === singular ||
|
|
480
|
+
param === `${singular}_id` ||
|
|
481
|
+
param === `${singular}_id_or_slug` ||
|
|
482
|
+
param === `${singular}_slug`
|
|
483
|
+
);
|
|
484
|
+
};
|
|
485
|
+
const createTags = new Set(createEp.tags ?? []);
|
|
486
|
+
const sharedTag = (ep: EndpointInfo) =>
|
|
487
|
+
(ep.tags ?? []).some(t => createTags.has(t));
|
|
488
|
+
|
|
489
|
+
resolvedItemEndpoints = endpoints.filter(
|
|
490
|
+
ep =>
|
|
491
|
+
!ep.deprecated &&
|
|
492
|
+
ep.path !== createEp.path &&
|
|
493
|
+
matchesResourceParam(ep.path) &&
|
|
494
|
+
sharedTag(ep),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
261
497
|
|
|
262
|
-
|
|
498
|
+
const read = resolvedItemEndpoints.find(ep => ep.method.toUpperCase() === "GET");
|
|
499
|
+
const update = resolvedItemEndpoints.find(
|
|
500
|
+
ep => ["PUT", "PATCH"].includes(ep.method.toUpperCase()),
|
|
501
|
+
);
|
|
502
|
+
const del = resolvedItemEndpoints.find(ep => ep.method.toUpperCase() === "DELETE");
|
|
503
|
+
// List endpoint matches with the same trailing-slash tolerance.
|
|
504
|
+
const list = endpoints.find(
|
|
505
|
+
ep =>
|
|
506
|
+
ep.method.toUpperCase() === "GET" &&
|
|
507
|
+
stripTrailingSlash(ep.path) === basePath &&
|
|
508
|
+
!ep.deprecated,
|
|
509
|
+
);
|
|
263
510
|
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
511
|
+
const diag: CrudDetectionDiagnostic = {
|
|
512
|
+
resource,
|
|
513
|
+
basePath,
|
|
514
|
+
postPath: createEp.path,
|
|
515
|
+
hasGetById: !!read,
|
|
516
|
+
hasUpdate: !!update,
|
|
517
|
+
hasDelete: !!del,
|
|
518
|
+
hasList: !!list,
|
|
519
|
+
verdict: "skipped",
|
|
520
|
+
reason: "",
|
|
521
|
+
};
|
|
268
522
|
|
|
269
|
-
|
|
270
|
-
|
|
523
|
+
if (resolvedItemEndpoints.length === 0) {
|
|
524
|
+
diag.reason = `no item endpoint matching ${basePath}/{...}`;
|
|
525
|
+
diagnostics.push(diag);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
// TASK-260: accept headless chains — POST + (GET/PUT/PATCH/DELETE on /{id}).
|
|
529
|
+
// Resources with no GET-by-id (e.g. external-teams, some user-binding endpoints)
|
|
530
|
+
// were previously skipped entirely, even though POST captures the ID and PUT/DELETE
|
|
531
|
+
// can drive the chain on their own. The Read/Verify steps in the suite generator
|
|
532
|
+
// are already conditional on `group.read`, so headless chains generate cleanly.
|
|
533
|
+
if (!read && !update && !del) {
|
|
534
|
+
diag.reason = "item endpoint exists but no GET/PUT/PATCH/DELETE on /{id}";
|
|
535
|
+
diagnostics.push(diag);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
271
538
|
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
-
|
|
539
|
+
const itemPath = resolvedItemEndpoints[0]!.path;
|
|
540
|
+
const idMatch = itemPath.match(/\{([^}]+)\}\/?$/);
|
|
541
|
+
if (!idMatch) {
|
|
542
|
+
diag.reason = "item path has no terminal {param}";
|
|
543
|
+
diagnostics.push(diag);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const idParam = idMatch[1]!;
|
|
275
547
|
|
|
276
|
-
|
|
548
|
+
diag.verdict = "chain";
|
|
549
|
+
if (read) {
|
|
550
|
+
diag.reason = "POST + GET/{id} matched";
|
|
551
|
+
} else {
|
|
552
|
+
// TASK-260: explicit headless reason so `--explain` differentiates the
|
|
553
|
+
// two chain shapes — useful for debugging fixture flow.
|
|
554
|
+
const partner = update ? `${update.method.toUpperCase()}/{id}` : `DELETE/{id}`;
|
|
555
|
+
diag.reason = `POST + ${partner} matched (headless: no GET-by-id)`;
|
|
556
|
+
}
|
|
557
|
+
diagnostics.push(diag);
|
|
277
558
|
|
|
278
559
|
groups.push({
|
|
279
560
|
resource,
|
|
@@ -288,7 +569,7 @@ export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
|
|
|
288
569
|
});
|
|
289
570
|
}
|
|
290
571
|
|
|
291
|
-
return groups;
|
|
572
|
+
return { groups, diagnostics };
|
|
292
573
|
}
|
|
293
574
|
|
|
294
575
|
/** Generate a CRUD chain suite from a CrudGroup */
|
|
@@ -296,8 +577,16 @@ export function generateCrudSuite(
|
|
|
296
577
|
group: CrudGroup,
|
|
297
578
|
securitySchemes: SecuritySchemeInfo[],
|
|
298
579
|
): RawSuite {
|
|
299
|
-
const captureField = group.create ? getCaptureField(group.create) : "id";
|
|
300
|
-
|
|
580
|
+
const captureField = group.create ? getCaptureField(group.create, group.idParam) : "id";
|
|
581
|
+
// ARV-137: use the spec's path-param name as the capture var. Previously
|
|
582
|
+
// we synthesised `<resource>_id` via `resourceVar(...)`, which produced
|
|
583
|
+
// phantom manifest dupes whenever the spec named the path-param anything
|
|
584
|
+
// other than `<resource>_id` (e.g. `monitor_id_or_slug`, `version`, or
|
|
585
|
+
// collection-stem mismatches like resource=`saved`/idParam=`query_id`).
|
|
586
|
+
// Aligning on `group.idParam` keeps tests, manifest, and spec consistent.
|
|
587
|
+
// Fallback to `resourceVar` only when the group has no idParam (defensive
|
|
588
|
+
// — shouldn't happen for any group with a read/update/delete endpoint).
|
|
589
|
+
const captureVar = group.idParam || resourceVar(group.resource, "id");
|
|
301
590
|
const tests: RawStep[] = [];
|
|
302
591
|
|
|
303
592
|
const allEps = [group.create, group.list, group.read, group.update, group.delete].filter(Boolean) as EndpointInfo[];
|
|
@@ -322,7 +611,8 @@ export function generateCrudSuite(
|
|
|
322
611
|
// 2. Read created
|
|
323
612
|
if (group.read) {
|
|
324
613
|
const step: RawStep = {
|
|
325
|
-
name: group.read.operationId ?? `Read created ${group.resource
|
|
614
|
+
name: group.read.operationId ?? `Read created ${singularizeResource(group.resource)}`,
|
|
615
|
+
source: buildStepSource(group.read),
|
|
326
616
|
GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
327
617
|
expect: {
|
|
328
618
|
status: getExpectedStatus(group.read),
|
|
@@ -336,12 +626,13 @@ export function generateCrudSuite(
|
|
|
336
626
|
if (group.update) {
|
|
337
627
|
const method = group.update.method.toUpperCase();
|
|
338
628
|
const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
|
|
339
|
-
const etagVar =
|
|
629
|
+
const etagVar = resourceVar(group.resource, "etag");
|
|
340
630
|
|
|
341
631
|
// If endpoint requires ETag (optimistic locking), capture it from a GET step first
|
|
342
632
|
if (group.update.requiresEtag && group.read) {
|
|
343
633
|
tests.push({
|
|
344
|
-
name: `Get ETag before update ${group.resource
|
|
634
|
+
name: `Get ETag before update ${singularizeResource(group.resource)}`,
|
|
635
|
+
source: buildStepSource(group.read),
|
|
345
636
|
GET: itemPath,
|
|
346
637
|
expect: {
|
|
347
638
|
status: getExpectedStatus(group.read),
|
|
@@ -351,7 +642,8 @@ export function generateCrudSuite(
|
|
|
351
642
|
}
|
|
352
643
|
|
|
353
644
|
const step: RawStep = {
|
|
354
|
-
name: group.update.operationId ?? `Update ${group.resource
|
|
645
|
+
name: group.update.operationId ?? `Update ${singularizeResource(group.resource)}`,
|
|
646
|
+
source: buildStepSource(group.update),
|
|
355
647
|
[method]: itemPath,
|
|
356
648
|
expect: {
|
|
357
649
|
status: getExpectedStatus(group.update),
|
|
@@ -369,13 +661,14 @@ export function generateCrudSuite(
|
|
|
369
661
|
// 4. Delete
|
|
370
662
|
if (group.delete) {
|
|
371
663
|
const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
|
|
372
|
-
const etagVar =
|
|
664
|
+
const etagVar = resourceVar(group.resource, "etag");
|
|
373
665
|
|
|
374
666
|
// If delete requires ETag and update didn't already capture it, add a GET step
|
|
375
667
|
const updateAlreadyCapturedEtag = group.update?.requiresEtag;
|
|
376
668
|
if (group.delete.requiresEtag && group.read && !updateAlreadyCapturedEtag) {
|
|
377
669
|
tests.push({
|
|
378
|
-
name: `Get ETag before delete ${group.resource
|
|
670
|
+
name: `Get ETag before delete ${singularizeResource(group.resource)}`,
|
|
671
|
+
source: buildStepSource(group.read),
|
|
379
672
|
GET: itemPath,
|
|
380
673
|
expect: {
|
|
381
674
|
status: getExpectedStatus(group.read),
|
|
@@ -386,7 +679,8 @@ export function generateCrudSuite(
|
|
|
386
679
|
|
|
387
680
|
// T44: cleanup must run even if earlier assertions failed (tainted captures)
|
|
388
681
|
const step: RawStep = {
|
|
389
|
-
name: group.delete.operationId ?? `Delete ${group.resource
|
|
682
|
+
name: group.delete.operationId ?? `Delete ${singularizeResource(group.resource)}`,
|
|
683
|
+
source: buildStepSource(group.delete),
|
|
390
684
|
DELETE: itemPath,
|
|
391
685
|
always: true,
|
|
392
686
|
expect: {
|
|
@@ -401,7 +695,8 @@ export function generateCrudSuite(
|
|
|
401
695
|
// 5. Verify deleted — also always, so we confirm cleanup happened
|
|
402
696
|
if (group.read) {
|
|
403
697
|
tests.push({
|
|
404
|
-
name: `Verify ${group.resource
|
|
698
|
+
name: `Verify ${singularizeResource(group.resource)} deleted`,
|
|
699
|
+
source: buildStepSource(group.read, 404),
|
|
405
700
|
GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
406
701
|
always: true,
|
|
407
702
|
expect: {
|
|
@@ -594,7 +889,7 @@ function generateConsistentAuthSuite(
|
|
|
594
889
|
}
|
|
595
890
|
|
|
596
891
|
/** Generate 1-2 minimal tests for quick connectivity and auth validation */
|
|
597
|
-
|
|
892
|
+
function generateSanitySuite(opts: {
|
|
598
893
|
authEndpoints: EndpointInfo[];
|
|
599
894
|
nonAuthGetEndpoints: EndpointInfo[];
|
|
600
895
|
securitySchemes: SecuritySchemeInfo[];
|
|
@@ -631,11 +926,22 @@ export function generateSanitySuite(opts: {
|
|
|
631
926
|
export function generateSuites(opts: {
|
|
632
927
|
endpoints: EndpointInfo[];
|
|
633
928
|
securitySchemes: SecuritySchemeInfo[];
|
|
929
|
+
/** Path to OpenAPI spec, recorded in suite-level provenance. */
|
|
930
|
+
specPath?: string;
|
|
931
|
+
/** When true, deprecated endpoints are included instead of filtered out. */
|
|
932
|
+
includeDeprecated?: boolean;
|
|
933
|
+
/** ARV-212 (R13/F16): inject `Authorization: Bearer {{<varName>}}` at the
|
|
934
|
+
* suite level when the spec declares no securitySchemes but the workspace
|
|
935
|
+
* .env.yaml carries this auth-token variable. Lets generated suites talk
|
|
936
|
+
* to bare-spec APIs (GitHub) without going unauth. */
|
|
937
|
+
defaultAuthVar?: string;
|
|
634
938
|
}): RawSuite[] {
|
|
635
|
-
const { endpoints, securitySchemes } = opts;
|
|
939
|
+
const { endpoints, securitySchemes, specPath, includeDeprecated, defaultAuthVar } = opts;
|
|
940
|
+
_suiteDefaultAuthVar = defaultAuthVar ?? null;
|
|
636
941
|
|
|
637
|
-
// Filter deprecated
|
|
638
|
-
|
|
942
|
+
// Filter deprecated unless caller opted in. The list of skipped paths is
|
|
943
|
+
// exposed separately via `getSkippedDeprecated` for stdout reporting.
|
|
944
|
+
const active = includeDeprecated ? endpoints : endpoints.filter(ep => !ep.deprecated);
|
|
639
945
|
|
|
640
946
|
// Separate auth endpoints
|
|
641
947
|
const authEndpoints = active.filter(isAuthEndpoint);
|
|
@@ -670,27 +976,52 @@ export function generateSuites(opts: {
|
|
|
670
976
|
const paramlessGets = getEndpoints.filter(ep => !endpointHasPathParams(ep));
|
|
671
977
|
const pathParamGets = getEndpoints.filter(ep => endpointHasPathParams(ep));
|
|
672
978
|
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
|
|
979
|
+
// Positive smoke: paramless GETs (no env needed) + path-param GETs
|
|
980
|
+
// (with skip_if guards). TASK-240 — unified naming convention:
|
|
981
|
+
// always emit `smoke-<tag>-positive.yaml`, never the bare
|
|
982
|
+
// `smoke-<tag>.yaml`, so file listings don't have to explain why a
|
|
983
|
+
// tag has only `-negative` (e.g. a vendor-specific tag) or why two
|
|
984
|
+
// siblings differ in suffix shape.
|
|
985
|
+
const positiveTests = [
|
|
986
|
+
...paramlessGets.map(ep => {
|
|
676
987
|
const step = generateStep(ep, securitySchemes);
|
|
677
988
|
const seededPath = convertPathWithSeeds(ep.path, ep);
|
|
678
989
|
(step as any)[ep.method.toUpperCase()] = seededPath;
|
|
679
990
|
return step;
|
|
680
|
-
})
|
|
681
|
-
|
|
991
|
+
}),
|
|
992
|
+
...pathParamGets.map(ep => {
|
|
993
|
+
const step = generateStep(ep, securitySchemes);
|
|
994
|
+
// Path stays as {{param}} so user-provided env values flow in.
|
|
995
|
+
// skip_if guards an unset path-param without skipping paramless
|
|
996
|
+
// siblings that don't need a fixture.
|
|
997
|
+
const firstPathParam = ep.parameters.find(p => p.in === "path");
|
|
998
|
+
if (firstPathParam) {
|
|
999
|
+
step.skip_if = `{{${firstPathParam.name}}} ==`;
|
|
1000
|
+
}
|
|
1001
|
+
return step;
|
|
1002
|
+
}),
|
|
1003
|
+
];
|
|
1004
|
+
|
|
1005
|
+
if (positiveTests.length > 0) {
|
|
1006
|
+
const positiveEndpoints = [...paramlessGets, ...pathParamGets];
|
|
1007
|
+
const headers = getSuiteHeaders(positiveEndpoints, securitySchemes);
|
|
1008
|
+
// needs-id only when at least one test depends on a path-param
|
|
1009
|
+
// fixture — coverage downgrades these suites when env is empty.
|
|
1010
|
+
const tags = pathParamGets.length > 0
|
|
1011
|
+
? ["smoke", "positive", "needs-id"]
|
|
1012
|
+
: ["smoke", "positive"];
|
|
682
1013
|
|
|
683
1014
|
const suite: RawSuite = {
|
|
684
|
-
name: `${tagSlug}-smoke`,
|
|
685
|
-
tags
|
|
686
|
-
fileStem: `smoke-${tagSlug}`,
|
|
1015
|
+
name: `${tagSlug}-smoke-positive`,
|
|
1016
|
+
tags,
|
|
1017
|
+
fileStem: `smoke-${tagSlug}-positive`,
|
|
687
1018
|
base_url: "{{base_url}}",
|
|
688
|
-
tests,
|
|
1019
|
+
tests: positiveTests,
|
|
689
1020
|
};
|
|
690
1021
|
|
|
691
1022
|
if (headers) {
|
|
692
1023
|
suite.headers = headers;
|
|
693
|
-
for (const t of
|
|
1024
|
+
for (const t of positiveTests) {
|
|
694
1025
|
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
695
1026
|
delete (t as any).headers;
|
|
696
1027
|
}
|
|
@@ -731,40 +1062,6 @@ export function generateSuites(opts: {
|
|
|
731
1062
|
suites.push(suite);
|
|
732
1063
|
}
|
|
733
1064
|
|
|
734
|
-
// Positive smoke: path-param GETs with {{var}} placeholders + skip_if for unset env
|
|
735
|
-
if (pathParamGets.length > 0) {
|
|
736
|
-
const tests = pathParamGets.map(ep => {
|
|
737
|
-
const step = generateStep(ep, securitySchemes);
|
|
738
|
-
// Path stays as {{param}} so user-provided env values flow in
|
|
739
|
-
// Pick the first path param for skip_if guard (the resource ID)
|
|
740
|
-
const firstPathParam = ep.parameters.find(p => p.in === "path");
|
|
741
|
-
if (firstPathParam) {
|
|
742
|
-
step.skip_if = `{{${firstPathParam.name}}} ==`;
|
|
743
|
-
}
|
|
744
|
-
return step;
|
|
745
|
-
});
|
|
746
|
-
const headers = getSuiteHeaders(pathParamGets, securitySchemes);
|
|
747
|
-
|
|
748
|
-
const suite: RawSuite = {
|
|
749
|
-
name: `${tagSlug}-smoke-positive`,
|
|
750
|
-
tags: ["smoke", "positive", "needs-id"],
|
|
751
|
-
fileStem: `smoke-${tagSlug}-positive`,
|
|
752
|
-
base_url: "{{base_url}}",
|
|
753
|
-
tests,
|
|
754
|
-
};
|
|
755
|
-
|
|
756
|
-
if (headers) {
|
|
757
|
-
suite.headers = headers;
|
|
758
|
-
for (const t of tests) {
|
|
759
|
-
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
760
|
-
delete (t as any).headers;
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
suites.push(suite);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
1065
|
// Non-GET endpoints: split reset/system endpoints out of smoke-unsafe
|
|
769
1066
|
const nonGetEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() !== "GET");
|
|
770
1067
|
const resetEndpoints = nonGetEndpoints.filter(ep => RESET_PATH_RE.test(ep.path));
|
|
@@ -836,5 +1133,16 @@ export function generateSuites(opts: {
|
|
|
836
1133
|
const nonAuthGetEndpoints = nonAuth.filter(ep => ep.method.toUpperCase() === "GET");
|
|
837
1134
|
const sanitySuite = generateSanitySuite({ authEndpoints, nonAuthGetEndpoints, securitySchemes });
|
|
838
1135
|
|
|
839
|
-
|
|
1136
|
+
const allSuites = sanitySuite ? [sanitySuite, ...suites] : suites;
|
|
1137
|
+
|
|
1138
|
+
// Stamp suite-level provenance when a spec path is known.
|
|
1139
|
+
const suiteSrc = buildOpenApiSuiteSource(specPath);
|
|
1140
|
+
if (suiteSrc) {
|
|
1141
|
+
for (const s of allSuites) {
|
|
1142
|
+
s.source = suiteSrc;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
_suiteDefaultAuthVar = null; // ARV-212
|
|
1147
|
+
return allSuites;
|
|
840
1148
|
}
|