@icyouo/evt-cli 0.1.0 → 0.1.1
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/README.md +45 -7
- package/README.zh-CN.md +41 -7
- package/data/scanner.example.json +2 -5
- package/package.json +5 -2
- package/scripts/build-package.js +173 -0
- package/scripts/check-api-coverage.js +41 -0
- package/scripts/local-ci.js +38 -0
- package/scripts/sync-api-coverage.js +227 -0
- package/skills/evt-api-scanner/SKILL.md +60 -0
- package/skills/evt-api-scanner/scripts/discover.js +160 -0
- package/skills/evt-api-scanner/scripts/scan.js +231 -0
- package/src/config/serviceScanner.js +60 -10
- package/src/index.js +38 -1
- package/test/duration.test.js +11 -0
- package/test/flow.test.js +215 -0
- package/test/liveTester.test.js +54 -0
- package/test/requestBuilder.test.js +214 -0
- package/test/serviceScanner.test.js +196 -0
- package/test/template.test.js +18 -0
- package/test/yaml.test.js +20 -0
|
@@ -97,6 +97,7 @@ function normalizeConfig(config, baseDir = repoRoot) {
|
|
|
97
97
|
return {
|
|
98
98
|
...config,
|
|
99
99
|
root,
|
|
100
|
+
apiDir: config.apiDir ? path.resolve(baseDir, config.apiDir) : undefined,
|
|
100
101
|
targets: (config.targets || []).map((target) => ({ ...target }))
|
|
101
102
|
};
|
|
102
103
|
}
|
|
@@ -375,37 +376,85 @@ function normalizeSkillEndpoint(rawEndpoint, target, config) {
|
|
|
375
376
|
}
|
|
376
377
|
|
|
377
378
|
function scanSkillTarget(config, target) {
|
|
378
|
-
|
|
379
|
+
const resolvedTarget = resolveSkillTarget(target);
|
|
380
|
+
if (!resolvedTarget.command) {
|
|
379
381
|
throw new Error("Skill scanner target must define command");
|
|
380
382
|
}
|
|
381
|
-
const cwd =
|
|
382
|
-
const result = spawnSync(
|
|
383
|
+
const cwd = resolvedTarget.cwd ? path.resolve(config.root, resolvedTarget.cwd) : config.root;
|
|
384
|
+
const result = spawnSync(resolvedTarget.command, (resolvedTarget.args || []).map(String), {
|
|
383
385
|
cwd,
|
|
384
386
|
encoding: "utf8",
|
|
385
387
|
shell: false,
|
|
386
388
|
env: {
|
|
387
389
|
...process.env,
|
|
388
|
-
|
|
390
|
+
EVT_SCAN_ROOT: config.root,
|
|
391
|
+
...(resolvedTarget.env || {})
|
|
389
392
|
},
|
|
390
|
-
maxBuffer:
|
|
393
|
+
maxBuffer: resolvedTarget.maxBuffer || 10 * 1024 * 1024
|
|
391
394
|
});
|
|
392
395
|
|
|
393
396
|
if (result.error) {
|
|
394
|
-
throw new Error(`Skill scanner ${
|
|
397
|
+
throw new Error(`Skill scanner ${resolvedTarget.name || resolvedTarget.command} failed: ${result.error.message}`);
|
|
395
398
|
}
|
|
396
399
|
if (result.status !== 0) {
|
|
397
400
|
const stderr = String(result.stderr || "").trim();
|
|
398
|
-
throw new Error(`Skill scanner ${
|
|
401
|
+
throw new Error(`Skill scanner ${resolvedTarget.name || resolvedTarget.command} exited with ${result.status}${stderr ? `: ${stderr}` : ""}`);
|
|
399
402
|
}
|
|
400
403
|
|
|
401
|
-
return parseSkillOutput(result.stdout,
|
|
402
|
-
.map((endpoint) => normalizeSkillEndpoint(endpoint,
|
|
404
|
+
return parseSkillOutput(result.stdout, resolvedTarget)
|
|
405
|
+
.map((endpoint) => normalizeSkillEndpoint(endpoint, resolvedTarget, config));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function resolveSkillTarget(target) {
|
|
409
|
+
if (!target.skill) return target;
|
|
410
|
+
if (target.skill !== "evt-api-scanner") return target;
|
|
411
|
+
return {
|
|
412
|
+
...target,
|
|
413
|
+
name: target.name || "evt-api-scanner",
|
|
414
|
+
command: process.execPath,
|
|
415
|
+
args: target.args || [
|
|
416
|
+
path.join(cliRoot, "skills", "evt-api-scanner", "scripts", "scan.js"),
|
|
417
|
+
"--root",
|
|
418
|
+
"."
|
|
419
|
+
]
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function splitTargets(targets) {
|
|
424
|
+
const skillTargets = [];
|
|
425
|
+
const fallbackTargets = [];
|
|
426
|
+
for (const target of targets || []) {
|
|
427
|
+
if (isSkillTarget(target)) {
|
|
428
|
+
skillTargets.push(target);
|
|
429
|
+
} else {
|
|
430
|
+
fallbackTargets.push(target);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { skillTargets, fallbackTargets };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function scanTargets(config, targets) {
|
|
437
|
+
return targets.flatMap((target) => scanTarget(config, target));
|
|
403
438
|
}
|
|
404
439
|
|
|
405
440
|
function scanServices(options = {}) {
|
|
406
441
|
const config = loadScanConfig(options);
|
|
407
442
|
if (!config.targets || config.targets.length === 0) return [];
|
|
408
|
-
|
|
443
|
+
if (options.preferFallback) {
|
|
444
|
+
return scanTargets(config, config.targets.filter((target) => !isSkillTarget(target)));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const { skillTargets, fallbackTargets } = splitTargets(config.targets);
|
|
448
|
+
if (skillTargets.length === 0) {
|
|
449
|
+
return scanTargets(config, fallbackTargets);
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const skillEndpoints = scanTargets(config, skillTargets);
|
|
453
|
+
if (skillEndpoints.length > 0 || fallbackTargets.length === 0) return skillEndpoints;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (fallbackTargets.length === 0 || options.strictSkill) throw error;
|
|
456
|
+
}
|
|
457
|
+
return scanTargets(config, fallbackTargets);
|
|
409
458
|
}
|
|
410
459
|
|
|
411
460
|
function compareScanToRegistry(scanned, registry) {
|
|
@@ -422,5 +471,6 @@ module.exports = {
|
|
|
422
471
|
loadScanConfig,
|
|
423
472
|
defaultScanConfig,
|
|
424
473
|
parseCliScanOptions,
|
|
474
|
+
resolveSkillTarget,
|
|
425
475
|
repoRoot
|
|
426
476
|
};
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const { spawnSync } = require("node:child_process");
|
|
1
3
|
const { parseArgs } = require("./util/args");
|
|
2
4
|
const { parseJsonObject, stableJson } = require("./util/json");
|
|
3
5
|
const { redact } = require("./util/redact");
|
|
4
|
-
const { setConfigRoot } = require("./util/paths");
|
|
6
|
+
const { cliRoot, setConfigRoot } = require("./util/paths");
|
|
5
7
|
const {
|
|
6
8
|
listApiFiles,
|
|
7
9
|
listProfiles,
|
|
@@ -16,6 +18,8 @@ const { runFlow } = require("./flow/runner");
|
|
|
16
18
|
const { toCurl } = require("./http/requestBuilder");
|
|
17
19
|
const { compareScanToRegistry, scanServices } = require("./config/serviceScanner");
|
|
18
20
|
const { runLiveApiTest } = require("./api/liveTester");
|
|
21
|
+
const { runSyncApiCoverage } = require("../scripts/sync-api-coverage");
|
|
22
|
+
const { runApiCoverage } = require("../scripts/check-api-coverage");
|
|
19
23
|
|
|
20
24
|
function print(value, json = false) {
|
|
21
25
|
if (json || typeof value !== "string") {
|
|
@@ -32,7 +36,10 @@ function usage() {
|
|
|
32
36
|
" evt profile show <name>",
|
|
33
37
|
" evt api list",
|
|
34
38
|
" evt api show <id>",
|
|
39
|
+
" evt api discover [--config-root ./cli]",
|
|
35
40
|
" evt api scan [--missing] [--scan-config path/to/scanner.json]",
|
|
41
|
+
" evt api sync [--config-root ./cli]",
|
|
42
|
+
" evt api coverage [--config-root ./cli]",
|
|
36
43
|
" evt api call <id> [--profile local] [--set k=v] [--body '{...}'] [--dry-run]",
|
|
37
44
|
" evt api test-all [--profile local] [--include-dangerous] [--only namespace]",
|
|
38
45
|
" evt validate",
|
|
@@ -42,6 +49,17 @@ function usage() {
|
|
|
42
49
|
].join("\n");
|
|
43
50
|
}
|
|
44
51
|
|
|
52
|
+
function runNodeScript(relativeScript, args) {
|
|
53
|
+
const result = spawnSync(process.execPath, [path.join(cliRoot, relativeScript), ...args], {
|
|
54
|
+
cwd: process.cwd(),
|
|
55
|
+
stdio: "inherit",
|
|
56
|
+
shell: false
|
|
57
|
+
});
|
|
58
|
+
if (result.status !== 0) {
|
|
59
|
+
throw new Error(`${relativeScript} failed with exit code ${result.status || 1}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
function commonRuntime(options) {
|
|
46
64
|
const profile = loadProfile(options.profile || "local");
|
|
47
65
|
const cachePath = resolveCachePath(options.cache);
|
|
@@ -73,6 +91,25 @@ async function handleApi(tokens) {
|
|
|
73
91
|
const sub = tokens[0];
|
|
74
92
|
const options = parseArgs(tokens.slice(1));
|
|
75
93
|
setConfigRoot(options.configRoot);
|
|
94
|
+
|
|
95
|
+
if (sub === "discover") {
|
|
96
|
+
const args = tokens.slice(1);
|
|
97
|
+
if (!options.configRoot && !args.includes("--config-root")) {
|
|
98
|
+
args.push("--config-root", "./cli");
|
|
99
|
+
}
|
|
100
|
+
runNodeScript("skills/evt-api-scanner/scripts/discover.js", args);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (sub === "sync") {
|
|
104
|
+
runSyncApiCoverage(tokens.slice(1));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (sub === "coverage") {
|
|
108
|
+
const result = runApiCoverage(tokens.slice(1));
|
|
109
|
+
if (result.failed) throw new Error("API coverage failed");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
76
113
|
const registry = loadApiRegistry();
|
|
77
114
|
|
|
78
115
|
if (sub === "list") {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { parseDuration } = require("../src/util/duration");
|
|
4
|
+
|
|
5
|
+
test("parses duration values", () => {
|
|
6
|
+
assert.equal(parseDuration(1000), 1000);
|
|
7
|
+
assert.equal(parseDuration("1000"), 1000);
|
|
8
|
+
assert.equal(parseDuration("500ms"), 500);
|
|
9
|
+
assert.equal(parseDuration("2s"), 2000);
|
|
10
|
+
assert.equal(parseDuration("1m"), 60000);
|
|
11
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { runFlow } = require("../src/flow/runner");
|
|
4
|
+
const { loadApiRegistry, loadProfile } = require("../src/config/loaders");
|
|
5
|
+
|
|
6
|
+
test("runs login flow in dry-run mode without prompt", async () => {
|
|
7
|
+
const result = await runFlow({
|
|
8
|
+
name: "login",
|
|
9
|
+
inputs: {
|
|
10
|
+
email: { required: true },
|
|
11
|
+
password: { required: true },
|
|
12
|
+
code: { default: "" },
|
|
13
|
+
googleCode: { default: "" }
|
|
14
|
+
},
|
|
15
|
+
steps: [
|
|
16
|
+
{
|
|
17
|
+
id: "login",
|
|
18
|
+
call: "auth.login",
|
|
19
|
+
body: {
|
|
20
|
+
email: "{{inputs.email}}",
|
|
21
|
+
password: "{{inputs.password}}"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}, {
|
|
26
|
+
registry: loadApiRegistry(),
|
|
27
|
+
profile: loadProfile("local"),
|
|
28
|
+
cache: {},
|
|
29
|
+
cachePath: "/tmp/evt-cli-test-cache.json",
|
|
30
|
+
set: {
|
|
31
|
+
email: "user@example.com",
|
|
32
|
+
password: "secret"
|
|
33
|
+
},
|
|
34
|
+
dryRun: true,
|
|
35
|
+
noInteractive: true
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
assert.equal(result.flow, "login");
|
|
39
|
+
assert.equal(result.timeline.filter((item) => item.call).length, 1);
|
|
40
|
+
assert.equal(result.steps.login.request.method, "POST");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("fails when required cache save value is missing", async () => {
|
|
44
|
+
const cachePath = "/tmp/evt-cli-required-cache-test.json";
|
|
45
|
+
const originalFetch = global.fetch;
|
|
46
|
+
global.fetch = async () => ({
|
|
47
|
+
ok: true,
|
|
48
|
+
status: 200,
|
|
49
|
+
text: async () => JSON.stringify({ code: 0, data: { info: { email: "user@example.com" } } })
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await assert.rejects(
|
|
54
|
+
runFlow({
|
|
55
|
+
name: "save-required",
|
|
56
|
+
steps: [
|
|
57
|
+
{
|
|
58
|
+
id: "login",
|
|
59
|
+
call: "auth.login",
|
|
60
|
+
body: {
|
|
61
|
+
email: "user@example.com",
|
|
62
|
+
password: "secret"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
save: {
|
|
67
|
+
cache: {
|
|
68
|
+
token: "{{steps.login.response.data.token}}"
|
|
69
|
+
},
|
|
70
|
+
required: ["cache.token"]
|
|
71
|
+
}
|
|
72
|
+
}, {
|
|
73
|
+
registry: loadApiRegistry(),
|
|
74
|
+
profile: loadProfile("local"),
|
|
75
|
+
cache: {},
|
|
76
|
+
cachePath,
|
|
77
|
+
set: {},
|
|
78
|
+
dryRun: false,
|
|
79
|
+
noInteractive: true
|
|
80
|
+
}),
|
|
81
|
+
/required save value: cache\.token/
|
|
82
|
+
);
|
|
83
|
+
} finally {
|
|
84
|
+
global.fetch = originalFetch;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("supports step expect and extract variables", async () => {
|
|
89
|
+
const originalFetch = global.fetch;
|
|
90
|
+
global.fetch = async () => ({
|
|
91
|
+
ok: true,
|
|
92
|
+
status: 200,
|
|
93
|
+
text: async () => JSON.stringify({ code: 0, data: { token: "token-value" } })
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const result = await runFlow({
|
|
98
|
+
name: "expect-extract",
|
|
99
|
+
steps: [
|
|
100
|
+
{
|
|
101
|
+
id: "login",
|
|
102
|
+
call: "auth.login",
|
|
103
|
+
body: {
|
|
104
|
+
email: "user@example.com",
|
|
105
|
+
password: "secret"
|
|
106
|
+
},
|
|
107
|
+
expect: [
|
|
108
|
+
{ path: "response.code", equals: 0 },
|
|
109
|
+
{ path: "response.data.token", exists: true }
|
|
110
|
+
],
|
|
111
|
+
extract: {
|
|
112
|
+
token: "response.data.token"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
}, {
|
|
117
|
+
registry: loadApiRegistry(),
|
|
118
|
+
profile: loadProfile("local"),
|
|
119
|
+
cache: {},
|
|
120
|
+
cachePath: "/tmp/evt-cli-extract-cache-test.json",
|
|
121
|
+
set: {},
|
|
122
|
+
dryRun: false,
|
|
123
|
+
noInteractive: true
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.deepEqual(result.vars, ["token"]);
|
|
127
|
+
} finally {
|
|
128
|
+
global.fetch = originalFetch;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("updates flow cache for later authenticated steps", async () => {
|
|
133
|
+
const originalFetch = global.fetch;
|
|
134
|
+
const seen = [];
|
|
135
|
+
global.fetch = async (url, options) => {
|
|
136
|
+
seen.push({ url, options });
|
|
137
|
+
if (seen.length === 1) {
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
status: 200,
|
|
141
|
+
text: async () => JSON.stringify({ code: 0, data: { token: "token-value" } })
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
status: 200,
|
|
147
|
+
text: async () => JSON.stringify({ code: 0, data: { email: "user@example.com" } })
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await runFlow({
|
|
153
|
+
name: "flow-cache",
|
|
154
|
+
steps: [
|
|
155
|
+
{
|
|
156
|
+
id: "login",
|
|
157
|
+
call: "auth.login",
|
|
158
|
+
body: {
|
|
159
|
+
email: "user@example.com",
|
|
160
|
+
password: "secret"
|
|
161
|
+
},
|
|
162
|
+
extract: {
|
|
163
|
+
token: "response.data.token"
|
|
164
|
+
},
|
|
165
|
+
cache: {
|
|
166
|
+
token: "{{vars.token}}"
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: "user",
|
|
171
|
+
call: "todo.list"
|
|
172
|
+
}
|
|
173
|
+
]
|
|
174
|
+
}, {
|
|
175
|
+
registry: loadApiRegistry(),
|
|
176
|
+
profile: loadProfile("local"),
|
|
177
|
+
cache: {},
|
|
178
|
+
cachePath: "/tmp/evt-cli-flow-cache-test.json",
|
|
179
|
+
set: {},
|
|
180
|
+
dryRun: false,
|
|
181
|
+
noInteractive: true
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
assert.equal(seen[1].options.headers.Authorization, "Bearer token-value");
|
|
185
|
+
} finally {
|
|
186
|
+
global.fetch = originalFetch;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("skips response expectations during dry-run", async () => {
|
|
191
|
+
const result = await runFlow({
|
|
192
|
+
name: "dry-run-expect",
|
|
193
|
+
steps: [
|
|
194
|
+
{
|
|
195
|
+
id: "oauth",
|
|
196
|
+
call: "auth.login",
|
|
197
|
+
expect: { path: "response.code", equals: 0 },
|
|
198
|
+
extract: {
|
|
199
|
+
token: "response.data.token"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
}, {
|
|
204
|
+
registry: loadApiRegistry(),
|
|
205
|
+
profile: loadProfile("local"),
|
|
206
|
+
cache: {},
|
|
207
|
+
cachePath: "/tmp/evt-cli-dry-run-expect-cache-test.json",
|
|
208
|
+
set: {},
|
|
209
|
+
dryRun: true,
|
|
210
|
+
noInteractive: true
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
assert.equal(result.flow, "dry-run-expect");
|
|
214
|
+
assert.deepEqual(result.vars, []);
|
|
215
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { sampleArgsForEndpoint } = require("../src/api/liveTester");
|
|
4
|
+
|
|
5
|
+
test("builds live test values from profile fixtures and schema metadata", () => {
|
|
6
|
+
const endpoint = {
|
|
7
|
+
id: "demo.create",
|
|
8
|
+
namespace: "demo",
|
|
9
|
+
schema: {
|
|
10
|
+
path: {
|
|
11
|
+
id: { type: "integer", example: 9 }
|
|
12
|
+
},
|
|
13
|
+
query: {
|
|
14
|
+
page: { type: "integer", default: 1 },
|
|
15
|
+
mode: { type: "string", enum: ["fast", "slow"] }
|
|
16
|
+
},
|
|
17
|
+
body: {
|
|
18
|
+
name: { type: "string", required: true },
|
|
19
|
+
enabled: { type: "boolean" }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const args = sampleArgsForEndpoint(endpoint, {
|
|
25
|
+
profile: {
|
|
26
|
+
fixtures: {
|
|
27
|
+
defaults: {
|
|
28
|
+
name: "default-name"
|
|
29
|
+
},
|
|
30
|
+
namespaces: {
|
|
31
|
+
demo: {
|
|
32
|
+
mode: "slow"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
endpoints: {
|
|
36
|
+
"demo.create": {
|
|
37
|
+
path: {
|
|
38
|
+
id: 42
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
cache: {}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
assert.deepEqual(args, {
|
|
48
|
+
id: 42,
|
|
49
|
+
page: 1,
|
|
50
|
+
mode: "slow",
|
|
51
|
+
name: "default-name",
|
|
52
|
+
enabled: false
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const { buildRequest, toCurl } = require("../src/http/requestBuilder");
|
|
7
|
+
const { executeEndpoint } = require("../src/http/client");
|
|
8
|
+
const { redact } = require("../src/util/redact");
|
|
9
|
+
|
|
10
|
+
test("builds authenticated form-url-encoded requests", () => {
|
|
11
|
+
const request = buildRequest({
|
|
12
|
+
id: "trade.createOrder",
|
|
13
|
+
service: "trade",
|
|
14
|
+
method: "POST",
|
|
15
|
+
path: "/api/trade/orders",
|
|
16
|
+
auth: true,
|
|
17
|
+
bodyType: "formUrlEncoded",
|
|
18
|
+
body: {
|
|
19
|
+
symbol: "{{args.symbol}}",
|
|
20
|
+
orderSide: "{{args.orderSide}}",
|
|
21
|
+
origQty: "{{args.origQty}}",
|
|
22
|
+
positionSide: "{{args.positionSide}}",
|
|
23
|
+
price: "{{args.price}}"
|
|
24
|
+
}
|
|
25
|
+
}, {
|
|
26
|
+
profile: {
|
|
27
|
+
baseUrls: { default: "https://api.example.com", trade: "https://f.example.com" },
|
|
28
|
+
headers: { Platform: "IOS" },
|
|
29
|
+
auth: { header: "Authorization", scheme: "raw" }
|
|
30
|
+
},
|
|
31
|
+
cache: { token: "token-value" },
|
|
32
|
+
context: {
|
|
33
|
+
args: {
|
|
34
|
+
symbol: "btc_usdt",
|
|
35
|
+
orderSide: "BUY",
|
|
36
|
+
origQty: "1",
|
|
37
|
+
positionSide: "LONG"
|
|
38
|
+
},
|
|
39
|
+
profile: {},
|
|
40
|
+
cache: {},
|
|
41
|
+
env: {},
|
|
42
|
+
steps: {},
|
|
43
|
+
inputs: {}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
assert.equal(request.url, "https://f.example.com/api/trade/orders");
|
|
48
|
+
assert.equal(request.headers.Authorization, "token-value");
|
|
49
|
+
assert.equal(request.headers["Content-Type"], "application/x-www-form-urlencoded");
|
|
50
|
+
assert.equal(request.encodedBody, "symbol=btc_usdt&orderSide=BUY&origQty=1&positionSide=LONG");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("renders dynamic path parameters from args", () => {
|
|
54
|
+
const request = buildRequest({
|
|
55
|
+
id: "spot.cancelSpotOrder",
|
|
56
|
+
method: "PATCH",
|
|
57
|
+
path: "/api/spot/order/:code/cancel",
|
|
58
|
+
auth: false
|
|
59
|
+
}, {
|
|
60
|
+
profile: { baseUrl: "https://api.example.com", headers: {} },
|
|
61
|
+
cache: {},
|
|
62
|
+
context: { args: { code: "abc/123" }, profile: {}, cache: {}, env: {}, steps: {}, inputs: {} }
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(request.url, "https://api.example.com/api/spot/order/abc%2F123/cancel");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("materializes schema query and body values from args", () => {
|
|
69
|
+
const request = buildRequest({
|
|
70
|
+
id: "market.getKline",
|
|
71
|
+
service: "market",
|
|
72
|
+
method: "GET",
|
|
73
|
+
path: "/api/market/kline",
|
|
74
|
+
auth: false,
|
|
75
|
+
schema: {
|
|
76
|
+
query: {
|
|
77
|
+
symbol: { type: "string", required: true },
|
|
78
|
+
interval: { type: "string", default: "1m" },
|
|
79
|
+
limit: { type: "integer", default: 100 }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}, {
|
|
83
|
+
profile: { baseUrls: { default: "https://api.example.com", market: "https://m.example.com" }, headers: {} },
|
|
84
|
+
cache: {},
|
|
85
|
+
context: { args: { symbol: "btc_usdt" }, profile: {}, cache: {}, env: {}, steps: {}, inputs: {} }
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
assert.equal(request.url, "https://m.example.com/api/market/kline?symbol=btc_usdt&interval=1m&limit=100");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("validates required schema values", () => {
|
|
92
|
+
assert.throws(
|
|
93
|
+
() => buildRequest({
|
|
94
|
+
id: "market.getSymbolDetail",
|
|
95
|
+
service: "market",
|
|
96
|
+
method: "GET",
|
|
97
|
+
path: "/api/market/symbol/detail",
|
|
98
|
+
auth: false,
|
|
99
|
+
schema: {
|
|
100
|
+
query: {
|
|
101
|
+
symbol: { type: "string", required: true }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}, {
|
|
105
|
+
profile: { baseUrls: { default: "https://api.example.com", market: "https://m.example.com" }, headers: {} },
|
|
106
|
+
cache: {},
|
|
107
|
+
context: { args: {}, profile: {}, cache: {}, env: {}, steps: {}, inputs: {} }
|
|
108
|
+
}),
|
|
109
|
+
/Missing required parameter: symbol/
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("builds multipart file upload requests", () => {
|
|
114
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "evt-cli-"));
|
|
115
|
+
const filePath = path.join(dir, "avatar.png");
|
|
116
|
+
fs.writeFileSync(filePath, "fake image");
|
|
117
|
+
|
|
118
|
+
const request = buildRequest({
|
|
119
|
+
id: "user.uploadImage",
|
|
120
|
+
method: "POST",
|
|
121
|
+
path: "/api/user/upload/image",
|
|
122
|
+
auth: true,
|
|
123
|
+
bodyType: "multipart"
|
|
124
|
+
}, {
|
|
125
|
+
profile: {
|
|
126
|
+
baseUrl: "https://api.example.com",
|
|
127
|
+
headers: { "Content-Type": "application/json" },
|
|
128
|
+
auth: { header: "Authorization", scheme: "raw" }
|
|
129
|
+
},
|
|
130
|
+
cache: { token: "token-value" },
|
|
131
|
+
body: { file: filePath, contentType: "image/png" },
|
|
132
|
+
context: { args: {}, profile: {}, cache: {}, env: {}, steps: {}, inputs: {} }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
assert.equal(request.headers.Authorization, "token-value");
|
|
136
|
+
assert.equal(request.headers["Content-Type"], undefined);
|
|
137
|
+
assert.equal(request.encodedBody.constructor.name, "FormData");
|
|
138
|
+
assert.equal(request.encodedBody.get("file").name, "avatar.png");
|
|
139
|
+
assert.match(toCurl(request), /-F "file=@.*avatar\.png"/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("does not require multipart files during dry-run", () => {
|
|
143
|
+
const request = buildRequest({
|
|
144
|
+
id: "user.uploadImage",
|
|
145
|
+
method: "POST",
|
|
146
|
+
path: "/api/user/upload/image",
|
|
147
|
+
auth: false,
|
|
148
|
+
bodyType: "multipart"
|
|
149
|
+
}, {
|
|
150
|
+
profile: { baseUrl: "https://api.example.com", headers: {} },
|
|
151
|
+
cache: {},
|
|
152
|
+
body: { file: "/path/that/does/not/exist.png", contentType: "image/png" },
|
|
153
|
+
context: { args: {}, profile: {}, cache: {}, env: {}, steps: {}, inputs: {} },
|
|
154
|
+
dryRun: true
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
assert.equal(request.encodedBody, undefined);
|
|
158
|
+
assert.match(toCurl(request), /-F "file=@\/path\/that\/does\/not\/exist\.png"/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("redacts encoded bodies in dry-run output", () => {
|
|
162
|
+
const safe = redact({
|
|
163
|
+
body: {
|
|
164
|
+
password: "secret"
|
|
165
|
+
},
|
|
166
|
+
encodedBody: "{\"password\":\"secret\"}"
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
assert.equal(safe.body.password, "***");
|
|
170
|
+
assert.equal(safe.encodedBody, "***");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("keeps response business code visible while redacting request verification code", () => {
|
|
174
|
+
const safe = redact({
|
|
175
|
+
response: {
|
|
176
|
+
code: 0,
|
|
177
|
+
data: {
|
|
178
|
+
order: {
|
|
179
|
+
code: 123
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
body: {
|
|
184
|
+
code: "123456"
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
assert.equal(safe.response.code, 0);
|
|
189
|
+
assert.equal(safe.response.data.order.code, 123);
|
|
190
|
+
assert.equal(safe.body.code, "***");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("blocks dangerous endpoints without unsafe flag", async () => {
|
|
194
|
+
await assert.rejects(
|
|
195
|
+
executeEndpoint({
|
|
196
|
+
id: "trade.createOrder",
|
|
197
|
+
service: "trade",
|
|
198
|
+
method: "POST",
|
|
199
|
+
path: "/api/trade/orders",
|
|
200
|
+
auth: false,
|
|
201
|
+
dangerous: true,
|
|
202
|
+
bodyType: "formUrlEncoded"
|
|
203
|
+
}, {
|
|
204
|
+
profile: {
|
|
205
|
+
baseUrls: { default: "https://api.example.com", trade: "https://t.example.com" },
|
|
206
|
+
headers: {}
|
|
207
|
+
},
|
|
208
|
+
cache: {},
|
|
209
|
+
context: { args: {}, profile: {}, cache: {}, env: {}, steps: {}, inputs: {} },
|
|
210
|
+
dryRun: false
|
|
211
|
+
}),
|
|
212
|
+
/dangerous/
|
|
213
|
+
);
|
|
214
|
+
});
|