@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
|
@@ -2,23 +2,61 @@ import { executeRequest } from "./http-client.ts";
|
|
|
2
2
|
import { loadEnvironment, substituteString, substituteDeep } from "../parser/variables.ts";
|
|
3
3
|
import { getDb } from "../../db/schema.ts";
|
|
4
4
|
import { findCollectionByNameOrId } from "../../db/queries.ts";
|
|
5
|
+
import { encodeFormBody } from "./form-encode.ts";
|
|
5
6
|
|
|
6
|
-
function
|
|
7
|
-
const
|
|
7
|
+
function hasHeaderCI(headers: Record<string, string>, name: string): boolean {
|
|
8
|
+
const lower = name.toLowerCase();
|
|
9
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === lower);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function extractByPath(obj: unknown, path: string): unknown {
|
|
13
|
+
return extractByPathWithDiagnostic(obj, path).value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** ARV-70 (feedback round-01 / F11): same extractor as above but also
|
|
17
|
+
* reports *why* the path failed. The CLI surfaces this on stderr when
|
|
18
|
+
* `--json-path` returns undefined so users don't lose minutes debugging
|
|
19
|
+
* "empty stdout despite data[0].id is right there in the JSON" — the
|
|
20
|
+
* hint pinpoints the segment that didn't resolve (e.g. "body is a string
|
|
21
|
+
* — content-type was not application/json"). */
|
|
22
|
+
export function extractByPathWithDiagnostic(
|
|
23
|
+
obj: unknown,
|
|
24
|
+
path: string,
|
|
25
|
+
): { value: unknown; resolved: string[]; failedAt?: string; reason?: string } {
|
|
26
|
+
const segments = path.replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
|
|
27
|
+
const resolved: string[] = [];
|
|
8
28
|
let current: unknown = obj;
|
|
9
29
|
for (const seg of segments) {
|
|
10
|
-
if (current === null || current === undefined)
|
|
30
|
+
if (current === null || current === undefined) {
|
|
31
|
+
return { value: undefined, resolved, failedAt: seg, reason: "previous segment resolved to null/undefined" };
|
|
32
|
+
}
|
|
11
33
|
if (Array.isArray(current)) {
|
|
12
34
|
const idx = parseInt(seg, 10);
|
|
13
|
-
if (isNaN(idx))
|
|
35
|
+
if (isNaN(idx)) {
|
|
36
|
+
return { value: undefined, resolved, failedAt: seg, reason: `expected an array index, got non-numeric segment "${seg}"` };
|
|
37
|
+
}
|
|
38
|
+
if (idx < 0 || idx >= current.length) {
|
|
39
|
+
return { value: undefined, resolved, failedAt: seg, reason: `array index ${idx} out of bounds (length ${current.length})` };
|
|
40
|
+
}
|
|
14
41
|
current = current[idx];
|
|
15
|
-
} else if (typeof current ===
|
|
16
|
-
|
|
42
|
+
} else if (typeof current === "object") {
|
|
43
|
+
const obj = current as Record<string, unknown>;
|
|
44
|
+
if (!(seg in obj)) {
|
|
45
|
+
const keys = Object.keys(obj).slice(0, 8).join(", ");
|
|
46
|
+
return { value: undefined, resolved, failedAt: seg, reason: `key "${seg}" not in object (keys: ${keys || "<empty>"})` };
|
|
47
|
+
}
|
|
48
|
+
current = obj[seg];
|
|
17
49
|
} else {
|
|
18
|
-
return
|
|
50
|
+
return {
|
|
51
|
+
value: undefined,
|
|
52
|
+
resolved,
|
|
53
|
+
failedAt: seg,
|
|
54
|
+
reason: `cannot traverse "${seg}" — body is a ${typeof current === "string" ? "string (content-type may not be application/json)" : typeof current}`,
|
|
55
|
+
};
|
|
19
56
|
}
|
|
57
|
+
resolved.push(seg);
|
|
20
58
|
}
|
|
21
|
-
return current;
|
|
59
|
+
return { value: current, resolved };
|
|
22
60
|
}
|
|
23
61
|
|
|
24
62
|
export interface SendAdHocRequestOptions {
|
|
@@ -33,6 +71,15 @@ export interface SendAdHocRequestOptions {
|
|
|
33
71
|
maxResponseChars?: number;
|
|
34
72
|
dbPath?: string;
|
|
35
73
|
searchDir?: string;
|
|
74
|
+
/** Extra vars merged on top of env (e.g. captured values from a stored run). */
|
|
75
|
+
extraVars?: Record<string, unknown>;
|
|
76
|
+
/** When true, resolve interpolation but do not actually send the request. */
|
|
77
|
+
dryRun?: boolean;
|
|
78
|
+
/** ARV-149: when true, send the body as `application/x-www-form-urlencoded`.
|
|
79
|
+
* Parses `body` as JSON to lift fields, then re-encodes with bracket notation
|
|
80
|
+
* (Stripe-style nested keys). If `body` isn't JSON-parseable it's passed
|
|
81
|
+
* through verbatim, and only the Content-Type header is set. */
|
|
82
|
+
form?: boolean;
|
|
36
83
|
}
|
|
37
84
|
|
|
38
85
|
export interface SendAdHocRequestResult {
|
|
@@ -40,25 +87,75 @@ export interface SendAdHocRequestResult {
|
|
|
40
87
|
headers: Record<string, string>;
|
|
41
88
|
body: unknown;
|
|
42
89
|
duration_ms: number;
|
|
90
|
+
/** ARV-70: when --json-path failed to resolve, this carries which
|
|
91
|
+
* segment broke and why so the CLI can surface a hint on stderr. */
|
|
92
|
+
jsonPathDiagnostic?: { resolved: string[]; failedAt?: string; reason?: string };
|
|
43
93
|
}
|
|
44
94
|
|
|
45
|
-
export
|
|
95
|
+
export interface ResolvedRequest {
|
|
96
|
+
method: string;
|
|
97
|
+
url: string;
|
|
98
|
+
headers: Record<string, string>;
|
|
99
|
+
body?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function resolveAdHocRequest(options: SendAdHocRequestOptions): Promise<ResolvedRequest> {
|
|
46
103
|
let searchDir = options.searchDir ?? process.cwd();
|
|
47
104
|
if (options.collectionName) {
|
|
48
105
|
getDb(options.dbPath);
|
|
49
106
|
const col = findCollectionByNameOrId(options.collectionName);
|
|
50
|
-
if (col
|
|
107
|
+
if (!col) {
|
|
108
|
+
throw new Error(`API '${options.collectionName}' is not registered. Run \`zond add api <name> --base-url <url>\` first, or check the name with \`zond db collections\`.`);
|
|
109
|
+
}
|
|
110
|
+
if (col.base_dir) searchDir = col.base_dir;
|
|
111
|
+
}
|
|
112
|
+
const envVars = await loadEnvironment(options.envName, searchDir);
|
|
113
|
+
const vars = options.extraVars ? { ...envVars, ...options.extraVars } : envVars;
|
|
114
|
+
|
|
115
|
+
// Auto-prefix base_url for relative paths when --api is in play.
|
|
116
|
+
// Mirror the YAML-runner ergonomics: `zond request --api jp GET /users/1`
|
|
117
|
+
// should work the same as `... GET '{{base_url}}/users/1'`. We touch the URL
|
|
118
|
+
// only when it's clearly relative (leading "/") and has no scheme/template
|
|
119
|
+
// already, so absolute URLs and explicit {{var}} templates pass through.
|
|
120
|
+
let urlToResolve = options.url;
|
|
121
|
+
if (
|
|
122
|
+
options.collectionName
|
|
123
|
+
&& typeof vars.base_url === "string"
|
|
124
|
+
&& vars.base_url.length > 0
|
|
125
|
+
&& urlToResolve.startsWith("/")
|
|
126
|
+
&& !urlToResolve.startsWith("//")
|
|
127
|
+
) {
|
|
128
|
+
const base = vars.base_url.replace(/\/+$/, "");
|
|
129
|
+
urlToResolve = `${base}${urlToResolve}`;
|
|
51
130
|
}
|
|
52
|
-
const vars = await loadEnvironment(options.envName, searchDir);
|
|
53
131
|
|
|
54
|
-
const resolvedUrl = substituteString(
|
|
132
|
+
const resolvedUrl = substituteString(urlToResolve, vars) as string;
|
|
55
133
|
const parsedHeaders = options.headers ?? {};
|
|
56
134
|
const resolvedHeaders = Object.keys(parsedHeaders).length > 0 ? substituteDeep(parsedHeaders, vars) : {};
|
|
57
|
-
|
|
135
|
+
let resolvedBody = options.body ? substituteString(options.body, vars) as string : undefined;
|
|
58
136
|
|
|
59
|
-
// Auto-detect Content-Type for body if not explicitly set
|
|
60
137
|
const finalHeaders: Record<string, string> = { ...resolvedHeaders };
|
|
61
|
-
|
|
138
|
+
const hasContentType =
|
|
139
|
+
finalHeaders["Content-Type"] !== undefined || finalHeaders["content-type"] !== undefined;
|
|
140
|
+
|
|
141
|
+
// ARV-149: `--form` (or auto-detection from spec content type) re-encodes
|
|
142
|
+
// the JSON body as `application/x-www-form-urlencoded` with bracket
|
|
143
|
+
// notation. Stripe v1 and other Rails/PHP-style APIs declare ONLY form
|
|
144
|
+
// bodies on their mutating endpoints — sending JSON yields a 400
|
|
145
|
+
// "check that your POST content type is application/x-www-form-urlencoded".
|
|
146
|
+
if (resolvedBody && options.form) {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(resolvedBody);
|
|
149
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
150
|
+
resolvedBody = encodeFormBody(parsed as Record<string, unknown>);
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Body isn't JSON — assume it's already urlencoded; pass through verbatim.
|
|
154
|
+
}
|
|
155
|
+
if (!hasContentType) {
|
|
156
|
+
finalHeaders["Content-Type"] = "application/x-www-form-urlencoded";
|
|
157
|
+
}
|
|
158
|
+
} else if (resolvedBody && !hasContentType) {
|
|
62
159
|
try {
|
|
63
160
|
JSON.parse(resolvedBody);
|
|
64
161
|
finalHeaders["Content-Type"] = "application/json";
|
|
@@ -67,20 +164,55 @@ export async function sendAdHocRequest(options: SendAdHocRequestOptions): Promis
|
|
|
67
164
|
}
|
|
68
165
|
}
|
|
69
166
|
|
|
167
|
+
// TASK-231: when --api resolved an env with `auth_token`, auto-inject the
|
|
168
|
+
// standard `Authorization: Bearer …` header. This mirrors the YAML runner's
|
|
169
|
+
// behaviour (probes/suites template the same header from the security
|
|
170
|
+
// scheme) so `zond request --api X GET /…` doesn't 401 just because the
|
|
171
|
+
// user didn't repeat `--header "Authorization: Bearer {{auth_token}}"`.
|
|
172
|
+
// User-supplied Authorization always wins.
|
|
173
|
+
if (
|
|
174
|
+
options.collectionName
|
|
175
|
+
&& typeof vars.auth_token === "string"
|
|
176
|
+
&& vars.auth_token.length > 0
|
|
177
|
+
&& !hasHeaderCI(finalHeaders, "Authorization")
|
|
178
|
+
) {
|
|
179
|
+
finalHeaders["Authorization"] = `Bearer ${vars.auth_token}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
method: options.method,
|
|
184
|
+
url: resolvedUrl,
|
|
185
|
+
headers: finalHeaders,
|
|
186
|
+
...(resolvedBody !== undefined ? { body: resolvedBody } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Note: `options.form` is consumed inside `resolveAdHocRequest` itself —
|
|
191
|
+
// the encoded `body` string and Content-Type are baked into `finalHeaders`.
|
|
192
|
+
// `sendAdHocRequest` doesn't need to forward the flag separately.
|
|
193
|
+
|
|
194
|
+
export async function sendAdHocRequest(options: SendAdHocRequestOptions): Promise<SendAdHocRequestResult> {
|
|
195
|
+
const resolved = await resolveAdHocRequest(options);
|
|
196
|
+
|
|
70
197
|
const response = await executeRequest(
|
|
71
198
|
{
|
|
72
|
-
method:
|
|
73
|
-
url:
|
|
74
|
-
headers:
|
|
75
|
-
body:
|
|
199
|
+
method: resolved.method,
|
|
200
|
+
url: resolved.url,
|
|
201
|
+
headers: resolved.headers,
|
|
202
|
+
body: resolved.body,
|
|
76
203
|
},
|
|
77
204
|
options.timeout ? { timeout: options.timeout } : undefined,
|
|
78
205
|
);
|
|
79
206
|
|
|
80
207
|
let responseBody: unknown = response.body_parsed ?? response.body;
|
|
208
|
+
let jsonPathDiagnostic: SendAdHocRequestResult["jsonPathDiagnostic"];
|
|
81
209
|
|
|
82
210
|
if (options.jsonPath && responseBody !== undefined) {
|
|
83
|
-
|
|
211
|
+
const diag = extractByPathWithDiagnostic(responseBody, options.jsonPath);
|
|
212
|
+
responseBody = diag.value;
|
|
213
|
+
if (diag.value === undefined && diag.failedAt) {
|
|
214
|
+
jsonPathDiagnostic = { resolved: diag.resolved, failedAt: diag.failedAt, reason: diag.reason };
|
|
215
|
+
}
|
|
84
216
|
}
|
|
85
217
|
|
|
86
218
|
const result: SendAdHocRequestResult = {
|
|
@@ -88,6 +220,7 @@ export async function sendAdHocRequest(options: SendAdHocRequestOptions): Promis
|
|
|
88
220
|
headers: response.headers,
|
|
89
221
|
body: responseBody,
|
|
90
222
|
duration_ms: response.duration_ms,
|
|
223
|
+
...(jsonPathDiagnostic ? { jsonPathDiagnostic } : {}),
|
|
91
224
|
};
|
|
92
225
|
|
|
93
226
|
return result;
|
package/src/core/runner/types.ts
CHANGED
|
@@ -14,14 +14,26 @@ export interface HttpResponse {
|
|
|
14
14
|
body: string;
|
|
15
15
|
body_parsed?: unknown;
|
|
16
16
|
duration_ms: number;
|
|
17
|
+
/** TASK-144: number of network-level retries that preceded this response.
|
|
18
|
+
* 0 when the request succeeded on the first attempt. */
|
|
19
|
+
network_retry_count?: number;
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* `kind` lets the UI surface the assertion that matches the test's stated
|
|
24
|
+
* intent (primary) ahead of OpenAPI schema noise (schema) and housekeeping
|
|
25
|
+
* checks like duration/header asserts (auxiliary). Optional for backwards
|
|
26
|
+
* compatibility — older runs render as `primary` by default.
|
|
27
|
+
*/
|
|
28
|
+
export type AssertionKind = "primary" | "schema" | "auxiliary";
|
|
29
|
+
|
|
19
30
|
export interface AssertionResult {
|
|
20
31
|
field: string;
|
|
21
32
|
rule: string;
|
|
22
33
|
passed: boolean;
|
|
23
34
|
actual: unknown;
|
|
24
35
|
expected: unknown;
|
|
36
|
+
kind?: AssertionKind;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
export interface StepResult {
|
|
@@ -33,6 +45,32 @@ export interface StepResult {
|
|
|
33
45
|
assertions: AssertionResult[];
|
|
34
46
|
captures: Record<string, unknown>;
|
|
35
47
|
error?: string;
|
|
48
|
+
provenance?: import("../parser/types.ts").SourceMetadata | null;
|
|
49
|
+
/** TASK-101: classification of why this failure happened — definitely_bug,
|
|
50
|
+
* likely_bug, quirk, env_issue. `null` for passed/skipped/unclassifiable. */
|
|
51
|
+
failure_class?: import("../diagnostics/failure-class.ts").FailureClass | null;
|
|
52
|
+
failure_class_reason?: string | null;
|
|
53
|
+
/** TASK-102: JSON Pointer into the OpenAPI doc + frozen excerpt of the
|
|
54
|
+
* schema at that pointer. Captured at run time so later spec edits don't
|
|
55
|
+
* rewrite history. `null` for manual YAML or when spec isn't available. */
|
|
56
|
+
spec_pointer?: string | null;
|
|
57
|
+
spec_excerpt?: string | null;
|
|
58
|
+
/** TASK-144: how many network-level retries the http-client performed
|
|
59
|
+
* before this step settled. Omitted (or 0) when no retry was needed.
|
|
60
|
+
* Surfaced in the JSON report so flaky-network steps are visible. */
|
|
61
|
+
network_retry?: number;
|
|
62
|
+
/** ARV-157: summary of `--validate-schema` outcome for this step. Present
|
|
63
|
+
* only when the runner had a schema validator attached AND a JSON body
|
|
64
|
+
* was returned (so consumers can distinguish "no drift" from "never
|
|
65
|
+
* validated"). Granular failures still live in `assertions[]` with
|
|
66
|
+
* `kind: "schema"`; this block is the at-a-glance shape skill docs
|
|
67
|
+
* describe and JSON-report consumers grep for. */
|
|
68
|
+
schema_validation?: {
|
|
69
|
+
result: "PASS" | "FAIL" | "no-endpoint" | "no-schema";
|
|
70
|
+
matched_endpoint: { method: string; path: string } | null;
|
|
71
|
+
matched_response_status: string | null;
|
|
72
|
+
error_count: number;
|
|
73
|
+
};
|
|
36
74
|
}
|
|
37
75
|
|
|
38
76
|
export interface TestRunResult {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SecretRegistry — runtime registry of secret values + sanitizer
|
|
3
|
+
* (TASK-166, m-10).
|
|
4
|
+
*
|
|
5
|
+
* The registry is the single point that knows "this string is a secret;
|
|
6
|
+
* if you see it anywhere in a request URL, body, response, log line, or
|
|
7
|
+
* exporter, replace it with `<redacted:<var-name>>`". Every persisted
|
|
8
|
+
* artifact path (DB-write — TASK-167, exporters — TASK-168) calls
|
|
9
|
+
* `redact()` / `redactObject()` before writing, so the user can ship a
|
|
10
|
+
* digest / HTML report without scrubbing tokens by hand.
|
|
11
|
+
*
|
|
12
|
+
* Design rules:
|
|
13
|
+
* - Exact-match only — no heuristics ("looks like a JWT", "starts with
|
|
14
|
+
* `sk_`"). False positives are worse than false negatives here.
|
|
15
|
+
* - Minimum length 8 — protects against `auth_token: ""` or `id: 1`
|
|
16
|
+
* turning every "1" in the report into `<redacted>`.
|
|
17
|
+
* - One marker format documented in one place: `<redacted:<name>>`.
|
|
18
|
+
* - `setEnabled(false)` returns a no-op redactor for `--no-redact` (local
|
|
19
|
+
* debug). Default is enabled.
|
|
20
|
+
*
|
|
21
|
+
* Marker format: `<redacted:auth_token>` — the name comes from the
|
|
22
|
+
* variable that registered the value (e.g. `.env.yaml` key, `--env` flag,
|
|
23
|
+
* `.secrets.yaml` future entry). Anything that opens a redacted artifact
|
|
24
|
+
* sees the variable name and knows where to look it up locally.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Minimum length below which a registered value is silently ignored. */
|
|
28
|
+
export const MIN_SECRET_LENGTH = 8;
|
|
29
|
+
|
|
30
|
+
const REDACTED_MARKER_RE = /<redacted:[a-zA-Z0-9_.-]+>/;
|
|
31
|
+
|
|
32
|
+
export interface SecretEntry {
|
|
33
|
+
name: string;
|
|
34
|
+
value: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class SecretRegistry {
|
|
38
|
+
/** value → name. We keep map keyed by *value* so a single redact pass
|
|
39
|
+
* iterates the unique values rather than all registrations. Two names
|
|
40
|
+
* registering the same value collapse to one entry — the most recent
|
|
41
|
+
* wins. */
|
|
42
|
+
private byValue = new Map<string, string>();
|
|
43
|
+
private enabled = true;
|
|
44
|
+
|
|
45
|
+
register(name: string, value: unknown): void {
|
|
46
|
+
if (typeof value !== "string") return;
|
|
47
|
+
if (value.length < MIN_SECRET_LENGTH) return;
|
|
48
|
+
this.byValue.set(value, name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bulk-register every string value in a flat object. Used by
|
|
53
|
+
* `.env.yaml` / `.secrets.yaml` loaders so we don't have to know in
|
|
54
|
+
* advance which keys are sensitive. The variable name carried into the
|
|
55
|
+
* marker is the object key.
|
|
56
|
+
*/
|
|
57
|
+
registerAll(entries: Record<string, unknown> | undefined | null): void {
|
|
58
|
+
if (!entries) return;
|
|
59
|
+
for (const [k, v] of Object.entries(entries)) this.register(k, v);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Disable redaction (for `--no-redact` local debug). */
|
|
63
|
+
setEnabled(enabled: boolean): void {
|
|
64
|
+
this.enabled = enabled;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isEnabled(): boolean {
|
|
68
|
+
return this.enabled;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Names of every var that had a value registered. Stable diagnostic. */
|
|
72
|
+
redactedNames(): string[] {
|
|
73
|
+
return [...new Set(this.byValue.values())].sort();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
hasSecrets(): boolean {
|
|
77
|
+
return this.byValue.size > 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Drop all registered secrets — used between test cases. */
|
|
81
|
+
clear(): void {
|
|
82
|
+
this.byValue.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Replace every occurrence of a registered value in `text` with the
|
|
87
|
+
* marker `<redacted:<name>>`. Longest values first, so a token that
|
|
88
|
+
* happens to contain a shorter registered substring still ends up
|
|
89
|
+
* redacted as the more-specific match.
|
|
90
|
+
*/
|
|
91
|
+
redact(text: string): string {
|
|
92
|
+
if (!this.enabled || this.byValue.size === 0) return text;
|
|
93
|
+
if (typeof text !== "string" || text.length === 0) return text;
|
|
94
|
+
|
|
95
|
+
let out = text;
|
|
96
|
+
for (const [value, name] of this.sortedEntries()) {
|
|
97
|
+
if (out.indexOf(value) === -1) continue;
|
|
98
|
+
out = out.split(value).join(`<redacted:${name}>`);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Deep-clone variant for arbitrary structured data (request/response
|
|
105
|
+
* bodies, header maps, JSON envelopes). Strings get redacted; numbers,
|
|
106
|
+
* booleans, null, Buffers stay as-is. Cycles are not expected on the
|
|
107
|
+
* artifact paths but we guard with `seen` to be safe.
|
|
108
|
+
*/
|
|
109
|
+
redactObject<T>(obj: T): T {
|
|
110
|
+
if (!this.enabled || this.byValue.size === 0) return obj;
|
|
111
|
+
return this.deepRedact(obj, new WeakSet()) as T;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private sortedEntries(): Array<[string, string]> {
|
|
115
|
+
return [...this.byValue.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private deepRedact(node: unknown, seen: WeakSet<object>): unknown {
|
|
119
|
+
if (node == null) return node;
|
|
120
|
+
if (typeof node === "string") return this.redact(node);
|
|
121
|
+
if (typeof node !== "object") return node;
|
|
122
|
+
if (seen.has(node as object)) return node;
|
|
123
|
+
seen.add(node as object);
|
|
124
|
+
|
|
125
|
+
if (Array.isArray(node)) {
|
|
126
|
+
return node.map((v) => this.deepRedact(v, seen));
|
|
127
|
+
}
|
|
128
|
+
// Buffers / Uint8Array / Date — leave intact.
|
|
129
|
+
if (node instanceof Uint8Array || node instanceof Date) return node;
|
|
130
|
+
|
|
131
|
+
const out: Record<string, unknown> = {};
|
|
132
|
+
for (const [k, v] of Object.entries(node)) {
|
|
133
|
+
out[k] = this.deepRedact(v, seen);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Process-wide registry. CLI commands populate it once after loading
|
|
141
|
+
* `.env.yaml` / `.secrets.yaml`; library callers can pass their own
|
|
142
|
+
* instance instead of touching this singleton in tests.
|
|
143
|
+
*/
|
|
144
|
+
let globalRegistry: SecretRegistry | undefined;
|
|
145
|
+
|
|
146
|
+
export function getSecretRegistry(): SecretRegistry {
|
|
147
|
+
if (!globalRegistry) globalRegistry = new SecretRegistry();
|
|
148
|
+
return globalRegistry;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Replace the global registry. Tests use this to reset state. */
|
|
152
|
+
export function setSecretRegistry(reg: SecretRegistry): void {
|
|
153
|
+
globalRegistry = reg;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Convenience: redact a string via the global registry. */
|
|
157
|
+
export function redact(text: string): string {
|
|
158
|
+
return getSecretRegistry().redact(text);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Convenience: redact a nested object via the global registry. */
|
|
162
|
+
export function redactObject<T>(value: T): T {
|
|
163
|
+
return getSecretRegistry().redactObject(value);
|
|
164
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.secrets.yaml` — gitignored flat YAML file holding raw secret values
|
|
3
|
+
* for an API (TASK-170, m-10). Companion to `.env.yaml` which references
|
|
4
|
+
* keys here via `@secret:<name>`.
|
|
5
|
+
*
|
|
6
|
+
* # apis/<name>/.secrets.yaml (NEVER committed)
|
|
7
|
+
* auth_token: "tok_..."
|
|
8
|
+
* dsn: "https://...@example.com/..."
|
|
9
|
+
*
|
|
10
|
+
* # apis/<name>/.env.yaml (committable)
|
|
11
|
+
* auth_token: "@secret:auth_token"
|
|
12
|
+
* base_url: "https://api.example.com"
|
|
13
|
+
*
|
|
14
|
+
* Mental model: anything in `.secrets.yaml` is registered with the
|
|
15
|
+
* `SecretRegistry` at load-time, so it gets redacted in any persisted
|
|
16
|
+
* artifact (DB, exporters, digests). Anything in `.env.yaml` is plain.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { getSecretRegistry } from "./registry.ts";
|
|
22
|
+
|
|
23
|
+
const SECRETS_FILENAME = ".secrets.yaml";
|
|
24
|
+
const SECRET_REF_RE = /^@secret:([A-Za-z_][A-Za-z0-9_.-]*)$/;
|
|
25
|
+
|
|
26
|
+
/** Resolved contents of a `.secrets.yaml`. */
|
|
27
|
+
export interface SecretsFile {
|
|
28
|
+
filePath: string;
|
|
29
|
+
values: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read `.secrets.yaml` from a directory, register every value with the
|
|
34
|
+
* global SecretRegistry, and return the parsed map. Returns `null` when
|
|
35
|
+
* the file is absent — callers should treat that as "no secrets to
|
|
36
|
+
* register" rather than a failure.
|
|
37
|
+
*/
|
|
38
|
+
export function loadSecretsFile(dir: string): SecretsFile | null {
|
|
39
|
+
const filePath = join(dir, SECRETS_FILENAME);
|
|
40
|
+
if (!existsSync(filePath)) return null;
|
|
41
|
+
const text = readFileSync(filePath, "utf-8");
|
|
42
|
+
let parsed: unknown;
|
|
43
|
+
try {
|
|
44
|
+
parsed = (Bun as any).YAML.parse(text);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new Error(`Failed to parse ${filePath}: ${(err as Error).message}`);
|
|
47
|
+
}
|
|
48
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
49
|
+
throw new Error(`${filePath} must contain a flat YAML object of key: "value" entries`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const values: Record<string, string> = {};
|
|
53
|
+
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
54
|
+
if (v == null) continue; // empty placeholder — skip
|
|
55
|
+
if (typeof v === "object") {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`${filePath}: nested values are not supported (key "${k}"). ` +
|
|
58
|
+
`.secrets.yaml is intentionally flat — keep one level of key/value pairs.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
values[k] = String(v);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const reg = getSecretRegistry();
|
|
65
|
+
reg.registerAll(values);
|
|
66
|
+
|
|
67
|
+
return { filePath, values };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Walk up a directory chain to find the first `.secrets.yaml` and load
|
|
72
|
+
* it. Used by the env loader so a single secrets file at the API root
|
|
73
|
+
* (`apis/<name>/.secrets.yaml`) is picked up regardless of which
|
|
74
|
+
* subdirectory `zond run` was invoked from.
|
|
75
|
+
*/
|
|
76
|
+
export function loadSecretsFromAncestor(start: string, stopAt?: string): SecretsFile | null {
|
|
77
|
+
let dir = start;
|
|
78
|
+
for (let i = 0; i < 8; i++) {
|
|
79
|
+
const file = loadSecretsFile(dir);
|
|
80
|
+
if (file) return file;
|
|
81
|
+
if (stopAt && dir === stopAt) return null;
|
|
82
|
+
const parent = dirname(dir);
|
|
83
|
+
if (parent === dir) return null;
|
|
84
|
+
dir = parent;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve any `@secret:<name>` reference inside an env object against
|
|
91
|
+
* the values from `secrets`. Throws when a referenced name is missing
|
|
92
|
+
* (fail-loud).
|
|
93
|
+
*/
|
|
94
|
+
export function resolveSecretRefs(
|
|
95
|
+
envValues: Record<string, string>,
|
|
96
|
+
secrets: SecretsFile | null,
|
|
97
|
+
filePath: string,
|
|
98
|
+
): Record<string, string> {
|
|
99
|
+
const out: Record<string, string> = { ...envValues };
|
|
100
|
+
for (const [k, v] of Object.entries(out)) {
|
|
101
|
+
const m = typeof v === "string" ? v.match(SECRET_REF_RE) : null;
|
|
102
|
+
if (!m) continue;
|
|
103
|
+
const refName = m[1]!;
|
|
104
|
+
const value = secrets?.values[refName];
|
|
105
|
+
if (value == null) {
|
|
106
|
+
const where = secrets ? secrets.filePath : `${dirname(filePath)}/${SECRETS_FILENAME}`;
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${filePath}: key "${k}" references @secret:${refName} but no such entry exists in ${where}. ` +
|
|
109
|
+
`Add \`${refName}: "<value>"\` to ${where} (or remove the @secret: prefix to use a literal value).`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
out[k] = value;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|