@kirrosh/zond 0.21.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 +758 -3
- package/README.md +78 -15
- package/package.json +17 -10
- 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 +55 -6
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +192 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +180 -8
- 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 -47
- package/src/cli/commands/init/agents-md.ts +61 -0
- package/src/cli/commands/init/bootstrap.ts +108 -0
- package/src/cli/commands/init/index.ts +244 -0
- package/src/cli/commands/init/skills.ts +98 -0
- package/src/cli/commands/init/templates/agents.md +77 -0
- package/src/cli/commands/init/templates/markdown.d.ts +4 -0
- 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 +651 -0
- package/src/cli/commands/init/templates/zond-config.yml +14 -0
- 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 +911 -33
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +74 -0
- package/src/cli/index.ts +36 -607
- package/src/cli/json-envelope.ts +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +218 -0
- 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 +8 -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 +51 -0
- 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 +161 -12
- 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 +53 -15
- 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 +490 -33
- 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 +55 -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 +114 -15
- package/src/core/generator/suite-generator.ts +484 -77
- 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 +132 -5
- package/src/core/parser/types.ts +29 -2
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +108 -13
- 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 +164 -0
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +691 -0
- 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 +69 -4
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +15 -2
- 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 +62 -2
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +391 -52
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +115 -7
- 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 +203 -0
- 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 +419 -17
- 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/root.ts +94 -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 +179 -48
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init.ts +0 -57
- package/src/cli/commands/serve.ts +0 -81
- package/src/cli/commands/sync.ts +0 -269
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -21
- 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}}");
|
|
@@ -28,6 +68,30 @@ function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
|
|
|
28
68
|
});
|
|
29
69
|
}
|
|
30
70
|
|
|
71
|
+
/**
|
|
72
|
+
* For negative-smoke suites: replace path params with guaranteed-non-existent values.
|
|
73
|
+
* Picks a value that's syntactically valid for the param's type/format but very
|
|
74
|
+
* unlikely to match a real resource (zero-UUID, very large int, sentinel string).
|
|
75
|
+
*/
|
|
76
|
+
function getNonexistentSeed(schema: OpenAPIV3.SchemaObject | undefined): string {
|
|
77
|
+
if (!schema) return "nonexistent_id_zzzzzz";
|
|
78
|
+
if (schema.format === "uuid") return "00000000-0000-0000-0000-000000000000";
|
|
79
|
+
if (schema.type === "integer" || schema.type === "number") return "999999999";
|
|
80
|
+
return "nonexistent_id_zzzzzz";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function convertPathWithBadIds(path: string, ep: EndpointInfo): string {
|
|
84
|
+
return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
85
|
+
const param = ep.parameters.find(p => p.name === name && p.in === "path");
|
|
86
|
+
const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
87
|
+
return getNonexistentSeed(schema);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function endpointHasPathParams(ep: EndpointInfo): boolean {
|
|
92
|
+
return ep.parameters.some(p => p.in === "path");
|
|
93
|
+
}
|
|
94
|
+
|
|
31
95
|
function slugify(s: string): string {
|
|
32
96
|
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
33
97
|
}
|
|
@@ -49,11 +113,32 @@ function selectHealthcheckEndpoint(gets: EndpointInfo[]): EndpointInfo | undefin
|
|
|
49
113
|
);
|
|
50
114
|
}
|
|
51
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
|
+
*/
|
|
52
127
|
function getExpectedStatus(ep: EndpointInfo): number {
|
|
53
128
|
const success = ep.responses.find(r => r.statusCode >= 200 && r.statusCode < 300);
|
|
54
129
|
if (success) return success.statusCode;
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
|
57
142
|
}
|
|
58
143
|
|
|
59
144
|
function getSuccessSchema(ep: EndpointInfo): OpenAPIV3.SchemaObject | undefined {
|
|
@@ -81,7 +166,7 @@ function getBodyAssertions(ep: EndpointInfo): Record<string, Record<string, stri
|
|
|
81
166
|
}
|
|
82
167
|
|
|
83
168
|
/** Derive a variable name for a security scheme's token */
|
|
84
|
-
function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
|
|
169
|
+
export function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
|
|
85
170
|
// Count how many bearer-like schemes exist
|
|
86
171
|
const bearerSchemes = allSchemes.filter(s =>
|
|
87
172
|
(s.type === "http" && (s.scheme === "bearer" || !s.scheme)) ||
|
|
@@ -98,29 +183,7 @@ function getAuthHeaders(
|
|
|
98
183
|
ep: EndpointInfo,
|
|
99
184
|
schemes: SecuritySchemeInfo[],
|
|
100
185
|
): Record<string, string> | undefined {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
for (const secName of ep.security) {
|
|
104
|
-
const scheme = schemes.find(s => s.name === secName);
|
|
105
|
-
if (!scheme) continue;
|
|
106
|
-
|
|
107
|
-
if (scheme.type === "http") {
|
|
108
|
-
if (scheme.scheme === "bearer" || !scheme.scheme) {
|
|
109
|
-
return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
|
|
110
|
-
}
|
|
111
|
-
if (scheme.scheme === "basic") {
|
|
112
|
-
return { Authorization: `Basic {{${schemeVarName(scheme, schemes)}}}` };
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
|
|
116
|
-
if (scheme.apiKeyName === "Authorization") {
|
|
117
|
-
return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
|
|
118
|
-
}
|
|
119
|
-
return { [scheme.apiKeyName]: "{{api_key}}" };
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return undefined;
|
|
186
|
+
return sharedGetAuthHeaders(ep, schemes, s => schemeVarName(s, schemes));
|
|
124
187
|
}
|
|
125
188
|
|
|
126
189
|
function getRequiredQueryParams(ep: EndpointInfo): Record<string, string> | undefined {
|
|
@@ -148,23 +211,88 @@ function getSuiteHeaders(
|
|
|
148
211
|
|
|
149
212
|
const headerSets = endpoints.map(ep => getAuthHeaders(ep, schemes));
|
|
150
213
|
const first = headerSets[0];
|
|
151
|
-
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
|
+
}
|
|
152
231
|
|
|
153
232
|
const firstJson = JSON.stringify(first);
|
|
154
233
|
const allSame = headerSets.every(h => JSON.stringify(h) === firstJson);
|
|
155
234
|
return allSame ? first : undefined;
|
|
156
235
|
}
|
|
157
236
|
|
|
158
|
-
|
|
159
|
-
|
|
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 {
|
|
160
268
|
const schema = getSuccessSchema(ep);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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;
|
|
166
287
|
}
|
|
167
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
|
+
|
|
168
296
|
return "id";
|
|
169
297
|
}
|
|
170
298
|
|
|
@@ -176,6 +304,46 @@ function isAuthEndpoint(ep: EndpointInfo): boolean {
|
|
|
176
304
|
return false;
|
|
177
305
|
}
|
|
178
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
|
+
|
|
179
347
|
// ──────────────────────────────────────────────
|
|
180
348
|
// Public API
|
|
181
349
|
// ──────────────────────────────────────────────
|
|
@@ -191,6 +359,7 @@ export function generateStep(
|
|
|
191
359
|
|
|
192
360
|
const step: RawStep = {
|
|
193
361
|
name,
|
|
362
|
+
source: buildStepSource(ep),
|
|
194
363
|
[method]: path,
|
|
195
364
|
expect: {
|
|
196
365
|
status: getExpectedStatus(ep),
|
|
@@ -205,6 +374,12 @@ export function generateStep(
|
|
|
205
374
|
if (["POST", "PUT", "PATCH"].includes(method) && ep.requestBodySchema) {
|
|
206
375
|
if (ep.requestBodyContentType === "multipart/form-data") {
|
|
207
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));
|
|
208
383
|
} else {
|
|
209
384
|
step.json = generateFromSchema(ep.requestBodySchema);
|
|
210
385
|
}
|
|
@@ -223,33 +398,163 @@ export function generateStep(
|
|
|
223
398
|
return step;
|
|
224
399
|
}
|
|
225
400
|
|
|
226
|
-
/**
|
|
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. */
|
|
227
438
|
export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
|
|
439
|
+
return detectCrudGroupsWithDiagnostics(endpoints).groups;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function detectCrudGroupsWithDiagnostics(
|
|
443
|
+
endpoints: EndpointInfo[],
|
|
444
|
+
): DetectCrudResult {
|
|
228
445
|
const groups: CrudGroup[] = [];
|
|
229
|
-
const
|
|
446
|
+
const diagnostics: CrudDetectionDiagnostic[] = [];
|
|
447
|
+
const postEndpoints = endpoints.filter(
|
|
448
|
+
ep => ep.method.toUpperCase() === "POST" && !ep.deprecated,
|
|
449
|
+
);
|
|
230
450
|
|
|
231
451
|
for (const createEp of postEndpoints) {
|
|
232
|
-
const basePath = createEp.path;
|
|
452
|
+
const basePath = stripTrailingSlash(createEp.path);
|
|
453
|
+
const resource = basePath.split("/").filter(Boolean).pop() ?? "resource";
|
|
233
454
|
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
);
|
|
237
462
|
|
|
238
|
-
|
|
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
|
+
}
|
|
239
497
|
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
);
|
|
244
510
|
|
|
245
|
-
const
|
|
246
|
-
|
|
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
|
+
};
|
|
247
522
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
}
|
|
251
538
|
|
|
252
|
-
const
|
|
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]!;
|
|
547
|
+
|
|
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);
|
|
253
558
|
|
|
254
559
|
groups.push({
|
|
255
560
|
resource,
|
|
@@ -264,7 +569,7 @@ export function detectCrudGroups(endpoints: EndpointInfo[]): CrudGroup[] {
|
|
|
264
569
|
});
|
|
265
570
|
}
|
|
266
571
|
|
|
267
|
-
return groups;
|
|
572
|
+
return { groups, diagnostics };
|
|
268
573
|
}
|
|
269
574
|
|
|
270
575
|
/** Generate a CRUD chain suite from a CrudGroup */
|
|
@@ -272,8 +577,16 @@ export function generateCrudSuite(
|
|
|
272
577
|
group: CrudGroup,
|
|
273
578
|
securitySchemes: SecuritySchemeInfo[],
|
|
274
579
|
): RawSuite {
|
|
275
|
-
const captureField = group.create ? getCaptureField(group.create) : "id";
|
|
276
|
-
|
|
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");
|
|
277
590
|
const tests: RawStep[] = [];
|
|
278
591
|
|
|
279
592
|
const allEps = [group.create, group.list, group.read, group.update, group.delete].filter(Boolean) as EndpointInfo[];
|
|
@@ -298,7 +611,8 @@ export function generateCrudSuite(
|
|
|
298
611
|
// 2. Read created
|
|
299
612
|
if (group.read) {
|
|
300
613
|
const step: RawStep = {
|
|
301
|
-
name: group.read.operationId ?? `Read created ${group.resource
|
|
614
|
+
name: group.read.operationId ?? `Read created ${singularizeResource(group.resource)}`,
|
|
615
|
+
source: buildStepSource(group.read),
|
|
302
616
|
GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
303
617
|
expect: {
|
|
304
618
|
status: getExpectedStatus(group.read),
|
|
@@ -312,12 +626,13 @@ export function generateCrudSuite(
|
|
|
312
626
|
if (group.update) {
|
|
313
627
|
const method = group.update.method.toUpperCase();
|
|
314
628
|
const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
|
|
315
|
-
const etagVar =
|
|
629
|
+
const etagVar = resourceVar(group.resource, "etag");
|
|
316
630
|
|
|
317
631
|
// If endpoint requires ETag (optimistic locking), capture it from a GET step first
|
|
318
632
|
if (group.update.requiresEtag && group.read) {
|
|
319
633
|
tests.push({
|
|
320
|
-
name: `Get ETag before update ${group.resource
|
|
634
|
+
name: `Get ETag before update ${singularizeResource(group.resource)}`,
|
|
635
|
+
source: buildStepSource(group.read),
|
|
321
636
|
GET: itemPath,
|
|
322
637
|
expect: {
|
|
323
638
|
status: getExpectedStatus(group.read),
|
|
@@ -327,7 +642,8 @@ export function generateCrudSuite(
|
|
|
327
642
|
}
|
|
328
643
|
|
|
329
644
|
const step: RawStep = {
|
|
330
|
-
name: group.update.operationId ?? `Update ${group.resource
|
|
645
|
+
name: group.update.operationId ?? `Update ${singularizeResource(group.resource)}`,
|
|
646
|
+
source: buildStepSource(group.update),
|
|
331
647
|
[method]: itemPath,
|
|
332
648
|
expect: {
|
|
333
649
|
status: getExpectedStatus(group.update),
|
|
@@ -345,13 +661,14 @@ export function generateCrudSuite(
|
|
|
345
661
|
// 4. Delete
|
|
346
662
|
if (group.delete) {
|
|
347
663
|
const itemPath = convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`);
|
|
348
|
-
const etagVar =
|
|
664
|
+
const etagVar = resourceVar(group.resource, "etag");
|
|
349
665
|
|
|
350
666
|
// If delete requires ETag and update didn't already capture it, add a GET step
|
|
351
667
|
const updateAlreadyCapturedEtag = group.update?.requiresEtag;
|
|
352
668
|
if (group.delete.requiresEtag && group.read && !updateAlreadyCapturedEtag) {
|
|
353
669
|
tests.push({
|
|
354
|
-
name: `Get ETag before delete ${group.resource
|
|
670
|
+
name: `Get ETag before delete ${singularizeResource(group.resource)}`,
|
|
671
|
+
source: buildStepSource(group.read),
|
|
355
672
|
GET: itemPath,
|
|
356
673
|
expect: {
|
|
357
674
|
status: getExpectedStatus(group.read),
|
|
@@ -360,9 +677,12 @@ export function generateCrudSuite(
|
|
|
360
677
|
});
|
|
361
678
|
}
|
|
362
679
|
|
|
680
|
+
// T44: cleanup must run even if earlier assertions failed (tainted captures)
|
|
363
681
|
const step: RawStep = {
|
|
364
|
-
name: group.delete.operationId ?? `Delete ${group.resource
|
|
682
|
+
name: group.delete.operationId ?? `Delete ${singularizeResource(group.resource)}`,
|
|
683
|
+
source: buildStepSource(group.delete),
|
|
365
684
|
DELETE: itemPath,
|
|
685
|
+
always: true,
|
|
366
686
|
expect: {
|
|
367
687
|
status: getExpectedStatus(group.delete),
|
|
368
688
|
},
|
|
@@ -372,11 +692,13 @@ export function generateCrudSuite(
|
|
|
372
692
|
}
|
|
373
693
|
tests.push(step);
|
|
374
694
|
|
|
375
|
-
// 5. Verify deleted
|
|
695
|
+
// 5. Verify deleted — also always, so we confirm cleanup happened
|
|
376
696
|
if (group.read) {
|
|
377
697
|
tests.push({
|
|
378
|
-
name: `Verify ${group.resource
|
|
698
|
+
name: `Verify ${singularizeResource(group.resource)} deleted`,
|
|
699
|
+
source: buildStepSource(group.read, 404),
|
|
379
700
|
GET: convertPath(group.itemPath).replace(`{{${group.idParam}}}`, `{{${captureVar}}}`),
|
|
701
|
+
always: true,
|
|
380
702
|
expect: {
|
|
381
703
|
status: 404,
|
|
382
704
|
},
|
|
@@ -384,9 +706,13 @@ export function generateCrudSuite(
|
|
|
384
706
|
}
|
|
385
707
|
}
|
|
386
708
|
|
|
709
|
+
// T28: classify by cleanup behavior. A suite that owns a DELETE leaves the API
|
|
710
|
+
// in its starting state (ephemeral); without DELETE it leaves residual data.
|
|
711
|
+
const cleanupTag = group.delete ? "ephemeral" : "persistent-write";
|
|
712
|
+
|
|
387
713
|
const suite: RawSuite = {
|
|
388
714
|
name: `${group.resource}-crud`,
|
|
389
|
-
tags: ["crud"],
|
|
715
|
+
tags: ["crud", cleanupTag],
|
|
390
716
|
fileStem: `crud-${slugify(group.resource)}`,
|
|
391
717
|
base_url: "{{base_url}}",
|
|
392
718
|
tests,
|
|
@@ -563,7 +889,7 @@ function generateConsistentAuthSuite(
|
|
|
563
889
|
}
|
|
564
890
|
|
|
565
891
|
/** Generate 1-2 minimal tests for quick connectivity and auth validation */
|
|
566
|
-
|
|
892
|
+
function generateSanitySuite(opts: {
|
|
567
893
|
authEndpoints: EndpointInfo[];
|
|
568
894
|
nonAuthGetEndpoints: EndpointInfo[];
|
|
569
895
|
securitySchemes: SecuritySchemeInfo[];
|
|
@@ -600,11 +926,22 @@ export function generateSanitySuite(opts: {
|
|
|
600
926
|
export function generateSuites(opts: {
|
|
601
927
|
endpoints: EndpointInfo[];
|
|
602
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;
|
|
603
938
|
}): RawSuite[] {
|
|
604
|
-
const { endpoints, securitySchemes } = opts;
|
|
939
|
+
const { endpoints, securitySchemes, specPath, includeDeprecated, defaultAuthVar } = opts;
|
|
940
|
+
_suiteDefaultAuthVar = defaultAuthVar ?? null;
|
|
605
941
|
|
|
606
|
-
// Filter deprecated
|
|
607
|
-
|
|
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);
|
|
608
945
|
|
|
609
946
|
// Separate auth endpoints
|
|
610
947
|
const authEndpoints = active.filter(isAuthEndpoint);
|
|
@@ -634,22 +971,81 @@ export function generateSuites(opts: {
|
|
|
634
971
|
for (const [tag, tagEndpoints] of byTag) {
|
|
635
972
|
const tagSlug = slugify(tag) || "api";
|
|
636
973
|
|
|
637
|
-
// GET endpoints →
|
|
974
|
+
// GET endpoints → split into paramless (regular smoke) and path-param (negative+positive smoke)
|
|
638
975
|
const getEndpoints = tagEndpoints.filter(ep => ep.method.toUpperCase() === "GET");
|
|
639
|
-
|
|
640
|
-
|
|
976
|
+
const paramlessGets = getEndpoints.filter(ep => !endpointHasPathParams(ep));
|
|
977
|
+
const pathParamGets = getEndpoints.filter(ep => endpointHasPathParams(ep));
|
|
978
|
+
|
|
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 => {
|
|
641
987
|
const step = generateStep(ep, securitySchemes);
|
|
642
|
-
// Replace path param placeholders with seed values so the suite runs out of the box
|
|
643
988
|
const seededPath = convertPathWithSeeds(ep.path, ep);
|
|
644
989
|
(step as any)[ep.method.toUpperCase()] = seededPath;
|
|
645
990
|
return step;
|
|
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"];
|
|
1013
|
+
|
|
1014
|
+
const suite: RawSuite = {
|
|
1015
|
+
name: `${tagSlug}-smoke-positive`,
|
|
1016
|
+
tags,
|
|
1017
|
+
fileStem: `smoke-${tagSlug}-positive`,
|
|
1018
|
+
base_url: "{{base_url}}",
|
|
1019
|
+
tests: positiveTests,
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
if (headers) {
|
|
1023
|
+
suite.headers = headers;
|
|
1024
|
+
for (const t of positiveTests) {
|
|
1025
|
+
if (t.headers && JSON.stringify(t.headers) === JSON.stringify(headers)) {
|
|
1026
|
+
delete (t as any).headers;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
suites.push(suite);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Negative smoke: path-param GETs with guaranteed-bad IDs, expect 400/404/422
|
|
1035
|
+
if (pathParamGets.length > 0) {
|
|
1036
|
+
const tests = pathParamGets.map(ep => {
|
|
1037
|
+
const step = generateStep(ep, securitySchemes);
|
|
1038
|
+
(step as any)[ep.method.toUpperCase()] = convertPathWithBadIds(ep.path, ep);
|
|
1039
|
+
// Negative path: resource doesn't exist. Drop body assertions (response shape varies).
|
|
1040
|
+
step.expect = { status: [400, 404, 422] };
|
|
1041
|
+
return step;
|
|
646
1042
|
});
|
|
647
|
-
const headers = getSuiteHeaders(
|
|
1043
|
+
const headers = getSuiteHeaders(pathParamGets, securitySchemes);
|
|
648
1044
|
|
|
649
1045
|
const suite: RawSuite = {
|
|
650
|
-
name: `${tagSlug}-smoke`,
|
|
651
|
-
tags: ["smoke"],
|
|
652
|
-
fileStem: `smoke-${tagSlug}`,
|
|
1046
|
+
name: `${tagSlug}-smoke-negative`,
|
|
1047
|
+
tags: ["smoke", "negative"],
|
|
1048
|
+
fileStem: `smoke-${tagSlug}-negative`,
|
|
653
1049
|
base_url: "{{base_url}}",
|
|
654
1050
|
tests,
|
|
655
1051
|
};
|
|
@@ -737,5 +1133,16 @@ export function generateSuites(opts: {
|
|
|
737
1133
|
const nonAuthGetEndpoints = nonAuth.filter(ep => ep.method.toUpperCase() === "GET");
|
|
738
1134
|
const sanitySuite = generateSanitySuite({ authEndpoints, nonAuthGetEndpoints, securitySchemes });
|
|
739
1135
|
|
|
740
|
-
|
|
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;
|
|
741
1148
|
}
|