@dichovsky/testrail-api-client 1.0.0 → 2.1.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/README.md +22 -0
- package/dist/cli/auth.d.ts +21 -0
- package/dist/cli/auth.js +16 -0
- package/dist/cli/body.d.ts +42 -0
- package/dist/cli/body.js +89 -0
- package/dist/cli/dispatch.d.ts +16 -0
- package/dist/cli/dispatch.js +87 -0
- package/dist/cli/handler-context.d.ts +43 -0
- package/dist/cli/handler-context.js +2 -0
- package/dist/cli/handlers/case-write.d.ts +4 -0
- package/dist/cli/handlers/case-write.js +26 -0
- package/dist/cli/handlers/case.d.ts +4 -0
- package/dist/cli/handlers/case.js +11 -0
- package/dist/cli/handlers/milestone.d.ts +4 -0
- package/dist/cli/handlers/milestone.js +15 -0
- package/dist/cli/handlers/project.d.ts +4 -0
- package/dist/cli/handlers/project.js +11 -0
- package/dist/cli/handlers/result-write.d.ts +4 -0
- package/dist/cli/handlers/result-write.js +40 -0
- package/dist/cli/handlers/result.d.ts +3 -0
- package/dist/cli/handlers/result.js +11 -0
- package/dist/cli/handlers/run-write.d.ts +10 -0
- package/dist/cli/handlers/run-write.js +29 -0
- package/dist/cli/handlers/run.d.ts +4 -0
- package/dist/cli/handlers/run.js +15 -0
- package/dist/cli/handlers/suite.d.ts +4 -0
- package/dist/cli/handlers/suite.js +10 -0
- package/dist/cli/handlers/user.d.ts +4 -0
- package/dist/cli/handlers/user.js +11 -0
- package/dist/cli/ids.d.ts +6 -0
- package/dist/cli/ids.js +20 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +198 -0
- package/dist/cli/install-skill.d.ts +35 -0
- package/dist/cli/install-skill.js +71 -0
- package/dist/cli/metadata.d.ts +37 -0
- package/dist/cli/metadata.js +151 -0
- package/dist/cli/output.d.ts +28 -0
- package/dist/cli/output.js +84 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -266
- package/dist/client-core.d.ts +16 -7
- package/dist/client-core.js +153 -27
- package/dist/client.d.ts +274 -118
- package/dist/client.js +404 -463
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/errors.d.ts +11 -9
- package/dist/errors.js +12 -8
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/modules/attachments.d.ts +19 -0
- package/dist/modules/attachments.js +64 -0
- package/dist/modules/cases.d.ts +13 -0
- package/dist/modules/cases.js +58 -0
- package/dist/modules/configurations.d.ts +14 -0
- package/dist/modules/configurations.js +37 -0
- package/dist/modules/datasets.d.ts +12 -0
- package/dist/modules/datasets.js +28 -0
- package/dist/modules/metadata.d.ts +14 -0
- package/dist/modules/metadata.js +31 -0
- package/dist/modules/milestones.d.ts +12 -0
- package/dist/modules/milestones.js +36 -0
- package/dist/modules/plans.d.ts +16 -0
- package/dist/modules/plans.js +59 -0
- package/dist/modules/projects.d.ts +36 -0
- package/dist/modules/projects.js +55 -0
- package/dist/modules/reports.d.ts +9 -0
- package/dist/modules/reports.js +16 -0
- package/dist/modules/results.d.ts +14 -0
- package/dist/modules/results.js +69 -0
- package/dist/modules/runs.d.ts +14 -0
- package/dist/modules/runs.js +57 -0
- package/dist/modules/sections.d.ts +16 -0
- package/dist/modules/sections.js +37 -0
- package/dist/modules/sharedSteps.d.ts +12 -0
- package/dist/modules/sharedSteps.js +28 -0
- package/dist/modules/suites.d.ts +37 -0
- package/dist/modules/suites.js +54 -0
- package/dist/modules/tests.d.ts +9 -0
- package/dist/modules/tests.js +25 -0
- package/dist/modules/users.d.ts +18 -0
- package/dist/modules/users.js +62 -0
- package/dist/modules/variables.d.ts +11 -0
- package/dist/modules/variables.js +24 -0
- package/dist/schemas.d.ts +544 -0
- package/dist/schemas.js +419 -0
- package/dist/types.d.ts +1 -55
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +4 -0
- package/package.json +23 -15
- package/skill/SKILL.md +395 -0
- package/src/cli/auth.ts +37 -0
- package/src/cli/body.ts +100 -0
- package/src/cli/dispatch.ts +91 -0
- package/src/cli/handler-context.ts +46 -0
- package/src/cli/handlers/case-write.ts +26 -0
- package/src/cli/handlers/case.ts +13 -0
- package/src/cli/handlers/milestone.ts +19 -0
- package/src/cli/handlers/project.ts +13 -0
- package/src/cli/handlers/result-write.ts +40 -0
- package/src/cli/handlers/result.ts +14 -0
- package/src/cli/handlers/run-write.ts +30 -0
- package/src/cli/handlers/run.ts +19 -0
- package/src/cli/handlers/suite.ts +12 -0
- package/src/cli/handlers/user.ts +13 -0
- package/src/cli/ids.ts +20 -0
- package/src/cli/index.ts +224 -0
- package/src/cli/install-skill.ts +89 -0
- package/src/cli/metadata.ts +194 -0
- package/src/cli/output.ts +96 -0
- package/src/cli.ts +1 -286
- package/src/client-core.ts +183 -67
- package/src/client.ts +414 -483
- package/src/constants.ts +1 -0
- package/src/errors.ts +18 -11
- package/src/index.ts +50 -8
- package/src/modules/attachments.ts +125 -0
- package/src/modules/cases.ts +78 -0
- package/src/modules/configurations.ts +68 -0
- package/src/modules/datasets.ts +44 -0
- package/src/modules/metadata.ts +63 -0
- package/src/modules/milestones.ts +54 -0
- package/src/modules/plans.ts +89 -0
- package/src/modules/projects.ts +67 -0
- package/src/modules/reports.ts +23 -0
- package/src/modules/results.ts +90 -0
- package/src/modules/runs.ts +70 -0
- package/src/modules/sections.ts +55 -0
- package/src/modules/sharedSteps.ts +44 -0
- package/src/modules/suites.ts +67 -0
- package/src/modules/tests.ts +28 -0
- package/src/modules/users.ts +87 -0
- package/src/modules/variables.ts +36 -0
- package/src/schemas.ts +551 -0
- package/src/types.ts +11 -60
- package/src/utils.ts +5 -0
package/dist/cli.js
CHANGED
|
@@ -1,268 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
|
-
import { TestRailClient } from './client.js';
|
|
5
|
-
// ── Version ───────────────────────────────────────────────────────────────────
|
|
6
|
-
const require = createRequire(import.meta.url);
|
|
7
|
-
const VERSION = require('../package.json').version;
|
|
8
|
-
// ── Arg Parsing ───────────────────────────────────────────────────────────────
|
|
9
|
-
const { values, positionals } = parseArgs({
|
|
10
|
-
args: process.argv.slice(2),
|
|
11
|
-
options: {
|
|
12
|
-
'base-url': { type: 'string' },
|
|
13
|
-
email: { type: 'string' },
|
|
14
|
-
'api-key': { type: 'string' },
|
|
15
|
-
format: { type: 'string', default: 'json' },
|
|
16
|
-
quiet: { type: 'boolean', default: false },
|
|
17
|
-
help: { type: 'boolean', default: false },
|
|
18
|
-
version: { type: 'boolean', default: false },
|
|
19
|
-
'project-id': { type: 'string' },
|
|
20
|
-
'suite-id': { type: 'string' },
|
|
21
|
-
'run-id': { type: 'string' },
|
|
22
|
-
'case-id': { type: 'string' },
|
|
23
|
-
limit: { type: 'string' },
|
|
24
|
-
offset: { type: 'string' },
|
|
25
|
-
},
|
|
26
|
-
allowPositionals: true,
|
|
27
|
-
strict: false,
|
|
28
|
-
});
|
|
29
|
-
// ── Output Helpers ────────────────────────────────────────────────────────────
|
|
30
|
-
const quiet = values.quiet === true;
|
|
31
|
-
const format = values.format ?? 'json';
|
|
32
|
-
function out(data) {
|
|
33
|
-
if (quiet)
|
|
34
|
-
return;
|
|
35
|
-
if (format === 'table') {
|
|
36
|
-
process.stdout.write(`${renderTable(data)}\n`);
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
function err(message) {
|
|
43
|
-
if (!quiet)
|
|
44
|
-
process.stderr.write(`Error: ${message}\n`);
|
|
45
|
-
}
|
|
46
|
-
function valueToString(v) {
|
|
47
|
-
if (v === null || v === undefined)
|
|
48
|
-
return '';
|
|
49
|
-
if (typeof v === 'object')
|
|
50
|
-
return JSON.stringify(v);
|
|
51
|
-
if (typeof v === 'string')
|
|
52
|
-
return v;
|
|
53
|
-
if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint')
|
|
54
|
-
return String(v);
|
|
55
|
-
if (typeof v === 'symbol')
|
|
56
|
-
return v.toString();
|
|
57
|
-
return '[Function]';
|
|
58
|
-
}
|
|
59
|
-
function renderTable(data) {
|
|
60
|
-
const rows = Array.isArray(data) ? data : [data];
|
|
61
|
-
if (rows.length === 0)
|
|
62
|
-
return '(empty)';
|
|
63
|
-
const first = rows[0];
|
|
64
|
-
if (typeof first !== 'object' || first === null) {
|
|
65
|
-
return rows.map(String).join('\n');
|
|
66
|
-
}
|
|
67
|
-
const keys = Object.keys(first);
|
|
68
|
-
const widths = keys.map((k) => Math.max(k.length, ...rows.map((r) => valueToString(r[k]).length)));
|
|
69
|
-
const line = widths.map((w) => '-'.repeat(w)).join('-+-');
|
|
70
|
-
const header = keys.map((k, i) => k.padEnd(widths[i] ?? k.length)).join(' | ');
|
|
71
|
-
const body = rows.map((r) => keys.map((k, i) => valueToString(r[k]).padEnd(widths[i] ?? k.length)).join(' | '));
|
|
72
|
-
return [header, line, ...body].join('\n');
|
|
73
|
-
}
|
|
74
|
-
// ── Help ──────────────────────────────────────────────────────────────────────
|
|
75
|
-
const HELP = `
|
|
76
|
-
testrail <resource> <action> [id] [options]
|
|
77
|
-
|
|
78
|
-
Resources & actions:
|
|
79
|
-
project get <id> | list [--limit N] [--offset N]
|
|
80
|
-
suite get <id> | list --project-id <id>
|
|
81
|
-
case get <id> | list --project-id <id> [--suite-id <id>]
|
|
82
|
-
run get <id> | list --project-id <id> [--limit N] [--offset N]
|
|
83
|
-
result list --run-id <id> [--limit N] [--offset N]
|
|
84
|
-
milestone get <id> | list --project-id <id> [--limit N] [--offset N]
|
|
85
|
-
user get <id> | list [--limit N] [--offset N]
|
|
86
|
-
|
|
87
|
-
Auth (env var or flag):
|
|
88
|
-
TESTRAIL_BASE_URL / --base-url <url>
|
|
89
|
-
TESTRAIL_EMAIL / --email <email>
|
|
90
|
-
TESTRAIL_API_KEY / --api-key <key>
|
|
91
|
-
|
|
92
|
-
Options:
|
|
93
|
-
--format json|table Output format (default: json)
|
|
94
|
-
--quiet Suppress output; use exit code 0/1
|
|
95
|
-
--help Show this help
|
|
96
|
-
--version Print version
|
|
97
|
-
`.trim();
|
|
98
|
-
// ── Entry Point ───────────────────────────────────────────────────────────────
|
|
99
|
-
if (values.version === true) {
|
|
100
|
-
process.stdout.write(`testrail-cli v${VERSION}\n`);
|
|
101
|
-
process.exit(0);
|
|
102
|
-
}
|
|
103
|
-
if (values.help === true || positionals.length === 0) {
|
|
104
|
-
process.stdout.write(`${HELP}\n`);
|
|
105
|
-
process.exit(0);
|
|
106
|
-
}
|
|
107
|
-
const [resource, action, idArg] = positionals;
|
|
108
|
-
if (resource === undefined || resource === '' || action === undefined || action === '') {
|
|
109
|
-
process.stderr.write('Usage: testrail <resource> <action> [id] [options]\nRun with --help for details.\n');
|
|
110
|
-
process.exit(1);
|
|
111
|
-
}
|
|
112
|
-
// ── Auth Resolution ───────────────────────────────────────────────────────────
|
|
113
|
-
const baseUrl = values['base-url'] ?? process.env['TESTRAIL_BASE_URL'];
|
|
114
|
-
const email = values['email'] ?? process.env['TESTRAIL_EMAIL'];
|
|
115
|
-
const apiKey = values['api-key'] ?? process.env['TESTRAIL_API_KEY'];
|
|
116
|
-
if (baseUrl === undefined ||
|
|
117
|
-
baseUrl === '' ||
|
|
118
|
-
email === undefined ||
|
|
119
|
-
email === '' ||
|
|
120
|
-
apiKey === undefined ||
|
|
121
|
-
apiKey === '') {
|
|
122
|
-
err('Missing auth. Set TESTRAIL_BASE_URL, TESTRAIL_EMAIL, TESTRAIL_API_KEY or use --base-url, --email, --api-key flags.');
|
|
123
|
-
process.exit(1);
|
|
124
|
-
}
|
|
125
|
-
const config = { baseUrl, email, apiKey };
|
|
126
|
-
const client = new TestRailClient(config);
|
|
127
|
-
// ── Numeric Helpers ───────────────────────────────────────────────────────────
|
|
128
|
-
function parseId(raw, name) {
|
|
129
|
-
const n = Number(raw);
|
|
130
|
-
if (raw === undefined || raw === '' || !Number.isInteger(n) || n <= 0) {
|
|
131
|
-
err(`${name} must be a positive integer (got: ${raw ?? '(none)'})`);
|
|
132
|
-
process.exit(1);
|
|
133
|
-
}
|
|
134
|
-
return n;
|
|
135
|
-
}
|
|
136
|
-
function optInt(raw) {
|
|
137
|
-
if (raw === undefined)
|
|
138
|
-
return undefined;
|
|
139
|
-
const n = Number(raw);
|
|
140
|
-
return Number.isInteger(n) && n >= 0 ? n : undefined;
|
|
141
|
-
}
|
|
142
|
-
const suiteId = optInt(values['suite-id']);
|
|
143
|
-
const limit = optInt(values.limit);
|
|
144
|
-
const offset = optInt(values.offset);
|
|
145
|
-
// ── Command Dispatch ──────────────────────────────────────────────────────────
|
|
146
|
-
async function run(res, act) {
|
|
147
|
-
switch (res) {
|
|
148
|
-
case 'project': {
|
|
149
|
-
if (act === 'get') {
|
|
150
|
-
const id = parseId(idArg, 'project id');
|
|
151
|
-
out(await client.getProject(id));
|
|
152
|
-
}
|
|
153
|
-
else if (act === 'list') {
|
|
154
|
-
out(await client.getProjects(limit, offset));
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
err(`Unknown action '${act}' for project. Use: get, list`);
|
|
158
|
-
process.exit(1);
|
|
159
|
-
}
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
case 'suite': {
|
|
163
|
-
if (act === 'get') {
|
|
164
|
-
const id = parseId(idArg, 'suite id');
|
|
165
|
-
out(await client.getSuite(id));
|
|
166
|
-
}
|
|
167
|
-
else if (act === 'list') {
|
|
168
|
-
const pid = parseId(values['project-id'], '--project-id');
|
|
169
|
-
out(await client.getSuites(pid));
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
172
|
-
err(`Unknown action '${act}' for suite. Use: get, list`);
|
|
173
|
-
process.exit(1);
|
|
174
|
-
}
|
|
175
|
-
break;
|
|
176
|
-
}
|
|
177
|
-
case 'case': {
|
|
178
|
-
if (act === 'get') {
|
|
179
|
-
const id = parseId(idArg, 'case id');
|
|
180
|
-
out(await client.getCase(id));
|
|
181
|
-
}
|
|
182
|
-
else if (act === 'list') {
|
|
183
|
-
const pid = parseId(values['project-id'], '--project-id');
|
|
184
|
-
out(await client.getCases(pid, suiteId !== undefined ? { suiteId } : undefined));
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
err(`Unknown action '${act}' for case. Use: get, list`);
|
|
188
|
-
process.exit(1);
|
|
189
|
-
}
|
|
190
|
-
break;
|
|
191
|
-
}
|
|
192
|
-
case 'run': {
|
|
193
|
-
if (act === 'get') {
|
|
194
|
-
const id = parseId(idArg, 'run id');
|
|
195
|
-
out(await client.getRun(id));
|
|
196
|
-
}
|
|
197
|
-
else if (act === 'list') {
|
|
198
|
-
const pid = parseId(values['project-id'], '--project-id');
|
|
199
|
-
out(await client.getRuns(pid, {
|
|
200
|
-
...(limit !== undefined && { limit }),
|
|
201
|
-
...(offset !== undefined && { offset }),
|
|
202
|
-
}));
|
|
203
|
-
}
|
|
204
|
-
else {
|
|
205
|
-
err(`Unknown action '${act}' for run. Use: get, list`);
|
|
206
|
-
process.exit(1);
|
|
207
|
-
}
|
|
208
|
-
break;
|
|
209
|
-
}
|
|
210
|
-
case 'result': {
|
|
211
|
-
if (act === 'list') {
|
|
212
|
-
const rid = parseId(values['run-id'], '--run-id');
|
|
213
|
-
const resultOpts = { ...(limit !== undefined && { limit }), ...(offset !== undefined && { offset }) };
|
|
214
|
-
out(await client.getResultsForRun(rid, resultOpts));
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
err(`Unknown action '${act}' for result. Use: list`);
|
|
218
|
-
process.exit(1);
|
|
219
|
-
}
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
|
-
case 'milestone': {
|
|
223
|
-
if (act === 'get') {
|
|
224
|
-
const id = parseId(idArg, 'milestone id');
|
|
225
|
-
out(await client.getMilestone(id));
|
|
226
|
-
}
|
|
227
|
-
else if (act === 'list') {
|
|
228
|
-
const pid = parseId(values['project-id'], '--project-id');
|
|
229
|
-
const msOpts = { ...(limit !== undefined && { limit }), ...(offset !== undefined && { offset }) };
|
|
230
|
-
out(await client.getMilestones(pid, msOpts));
|
|
231
|
-
}
|
|
232
|
-
else {
|
|
233
|
-
err(`Unknown action '${act}' for milestone. Use: get, list`);
|
|
234
|
-
process.exit(1);
|
|
235
|
-
}
|
|
236
|
-
break;
|
|
237
|
-
}
|
|
238
|
-
case 'user': {
|
|
239
|
-
if (act === 'get') {
|
|
240
|
-
const id = parseId(idArg, 'user id');
|
|
241
|
-
out(await client.getUser(id));
|
|
242
|
-
}
|
|
243
|
-
else if (act === 'list') {
|
|
244
|
-
out(await client.getUsers(limit, offset));
|
|
245
|
-
}
|
|
246
|
-
else {
|
|
247
|
-
err(`Unknown action '${act}' for user. Use: get, list`);
|
|
248
|
-
process.exit(1);
|
|
249
|
-
}
|
|
250
|
-
break;
|
|
251
|
-
}
|
|
252
|
-
default: {
|
|
253
|
-
err(`Unknown resource '${res}'. Use: project, suite, case, run, result, milestone, user`);
|
|
254
|
-
process.exit(1);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
run(resource, action)
|
|
259
|
-
.then(() => {
|
|
260
|
-
client.destroy();
|
|
261
|
-
process.exit(0);
|
|
262
|
-
})
|
|
263
|
-
.catch((e) => {
|
|
264
|
-
err(e instanceof Error ? e.message : String(e));
|
|
265
|
-
client.destroy();
|
|
266
|
-
process.exit(1);
|
|
267
|
-
});
|
|
2
|
+
import './cli/index.js';
|
|
268
3
|
//# sourceMappingURL=cli.js.map
|
package/dist/client-core.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TestRailConfig } from './types.js';
|
|
2
|
+
import { type ZodType } from 'zod';
|
|
2
3
|
/**
|
|
3
4
|
* HTTP pipeline, caching, rate limiting, retry logic, and lifecycle management.
|
|
4
5
|
* Extended by {@link TestRailClient} which adds all API endpoint methods.
|
|
@@ -16,6 +17,8 @@ export declare class TestRailClientCore {
|
|
|
16
17
|
private cacheCleanupTimer;
|
|
17
18
|
private readonly rateLimiter;
|
|
18
19
|
private isDestroyed;
|
|
20
|
+
private readonly dnsValidationPromise;
|
|
21
|
+
private dnsValidationError;
|
|
19
22
|
constructor(config: TestRailConfig);
|
|
20
23
|
private validateConfig;
|
|
21
24
|
private getRetryDelay;
|
|
@@ -32,24 +35,24 @@ export declare class TestRailClientCore {
|
|
|
32
35
|
* Validates that an ID is a positive integer.
|
|
33
36
|
* @throws {TestRailValidationError} When ID is invalid
|
|
34
37
|
*/
|
|
35
|
-
|
|
38
|
+
validateId(id: number, name: string): void;
|
|
36
39
|
/**
|
|
37
40
|
* Validates that a string entry ID is non-empty.
|
|
38
41
|
* @throws {TestRailValidationError} When entryId is not a non-empty string
|
|
39
42
|
*/
|
|
40
|
-
|
|
43
|
+
validateEntryId(entryId: string): void;
|
|
41
44
|
/**
|
|
42
45
|
* Validates optional pagination parameters.
|
|
43
46
|
* @throws {TestRailValidationError} When limit is not a positive integer or offset is not a non-negative integer
|
|
44
47
|
*/
|
|
45
|
-
|
|
48
|
+
validatePaginationParams(limit?: number, offset?: number): void;
|
|
46
49
|
/**
|
|
47
50
|
* Builds a TestRail endpoint URL with optional query parameters.
|
|
48
51
|
* Appends params using `&key=value` (TestRail URL quirk — uses `&`, not `?`).
|
|
49
52
|
* Keys and values are automatically percent-encoded via `encodeURIComponent`.
|
|
50
53
|
* Do NOT pre-encode values before passing them; doing so will cause double-encoding.
|
|
51
54
|
*/
|
|
52
|
-
|
|
55
|
+
buildEndpoint(base: string, params?: Record<string, string | number | undefined>): string;
|
|
53
56
|
private getCachedData;
|
|
54
57
|
private setCachedData;
|
|
55
58
|
/**
|
|
@@ -77,7 +80,7 @@ export declare class TestRailClientCore {
|
|
|
77
80
|
* @throws {TestRailApiError} When the API request fails or network error occurs
|
|
78
81
|
* @throws {Error} When called after `destroy()`
|
|
79
82
|
*/
|
|
80
|
-
|
|
83
|
+
request<T>(method: string, endpoint: string, data?: unknown, retryCount?: number, skipCache?: boolean): Promise<T>;
|
|
81
84
|
/**
|
|
82
85
|
* Makes a multipart/form-data POST request to the TestRail API.
|
|
83
86
|
* Used exclusively for file attachment uploads. Applies rate limiting
|
|
@@ -89,7 +92,7 @@ export declare class TestRailClientCore {
|
|
|
89
92
|
* @throws {TestRailApiError} When the API request fails or network error occurs
|
|
90
93
|
* @throws {Error} When called after `destroy()`
|
|
91
94
|
*/
|
|
92
|
-
|
|
95
|
+
requestMultipart<T>(endpoint: string, file: globalThis.Blob | Uint8Array | globalThis.File, filename: string): Promise<T>;
|
|
93
96
|
/**
|
|
94
97
|
* Makes a GET request to the TestRail API and returns the raw binary response.
|
|
95
98
|
* Used for downloading attachment contents.
|
|
@@ -98,6 +101,12 @@ export declare class TestRailClientCore {
|
|
|
98
101
|
* @throws {TestRailApiError} When the API request fails or network error occurs
|
|
99
102
|
* @throws {Error} When called after `destroy()`
|
|
100
103
|
*/
|
|
101
|
-
|
|
104
|
+
requestBinary(endpoint: string, retryCount?: number): Promise<ArrayBuffer>;
|
|
105
|
+
private awaitDnsValidation;
|
|
106
|
+
/**
|
|
107
|
+
* Validates `data` against `schema` and returns it typed as `T`.
|
|
108
|
+
* @throws {TestRailValidationError} When data does not conform to schema
|
|
109
|
+
*/
|
|
110
|
+
parse<T>(schema: ZodType, data: unknown): T;
|
|
102
111
|
}
|
|
103
112
|
//# sourceMappingURL=client-core.d.ts.map
|
package/dist/client-core.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { base64Encode, sleep } from './utils.js';
|
|
2
|
-
import { TestRailApiError, TestRailValidationError } from './errors.js';
|
|
2
|
+
import { TestRailApiError, TestRailValidationError, handleZodError } from './errors.js';
|
|
3
3
|
import pkg from '../package.json' with { type: 'json' };
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { ZodError } from 'zod';
|
|
4
6
|
const USER_AGENT = `${pkg.description}/${pkg.version}`;
|
|
5
|
-
import { BASE_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS, MAX_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRIES, DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_CLEANUP_INTERVAL_MS, DEFAULT_MAX_CACHE_SIZE, DEFAULT_RATE_LIMIT_MAX_REQUESTS, DEFAULT_RATE_LIMIT_WINDOW_MS, } from './constants.js';
|
|
7
|
+
import { BASE_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS, MAX_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRIES, DEFAULT_CACHE_TTL_MS, DEFAULT_CACHE_CLEANUP_INTERVAL_MS, DEFAULT_MAX_CACHE_SIZE, DEFAULT_RATE_LIMIT_MAX_REQUESTS, DEFAULT_RATE_LIMIT_WINDOW_MS, DEFAULT_DNS_VALIDATION_MAX_WAIT_MS, } from './constants.js';
|
|
6
8
|
// Reject loopback, link-local, and private-range hosts to prevent SSRF.
|
|
7
9
|
// All requests carry a full Authorization header, making the client a credentialed
|
|
8
10
|
// probe for internal services when baseUrl is attacker-controlled.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// Protection combines syntactic checks (regex on hostname string) with DNS resolution:
|
|
12
|
+
// validatePublicHost() resolves the hostname and checks resulting IPs. A DNS validation
|
|
13
|
+
// promise is awaited briefly before each request. This blocks obvious private-host
|
|
14
|
+
// resolutions but does not fully eliminate rebinding after initial validation.
|
|
13
15
|
const PRIVATE_HOST_PATTERNS = [
|
|
14
16
|
/^localhost\.?$/i, // matches "localhost" with or without trailing dot
|
|
15
17
|
/^127\./,
|
|
@@ -22,15 +24,74 @@ const PRIVATE_HOST_PATTERNS = [
|
|
|
22
24
|
/^f[cd][0-9a-f]{2}:/i, // IPv6 unique-local (fc00::/7 covers fc** and fd**)
|
|
23
25
|
/^0\./,
|
|
24
26
|
];
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
+
function isPrivateOrLoopbackIPv4(ip) {
|
|
28
|
+
const octetParts = ip.split('.');
|
|
29
|
+
if (octetParts.length !== 4 || octetParts.some((part) => !/^\d+$/.test(part))) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const octets = octetParts.map((part) => Number.parseInt(part, 10));
|
|
33
|
+
if (octets.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const [o0, o1] = octets;
|
|
37
|
+
return (o0 === 0 ||
|
|
38
|
+
o0 === 10 ||
|
|
39
|
+
o0 === 127 ||
|
|
40
|
+
(o0 === 169 && o1 === 254) ||
|
|
41
|
+
(o0 === 172 && o1 >= 16 && o1 <= 31) ||
|
|
42
|
+
(o0 === 192 && o1 === 168));
|
|
43
|
+
}
|
|
44
|
+
function isPrivateOrLoopbackIP(ip, family) {
|
|
45
|
+
const [normalized] = ip.toLowerCase().split('%');
|
|
46
|
+
// Handle IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1).
|
|
47
|
+
const mappedIPv4 = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
48
|
+
const mappedAddress = mappedIPv4?.[1];
|
|
49
|
+
if (mappedAddress !== undefined) {
|
|
50
|
+
return isPrivateOrLoopbackIPv4(mappedAddress);
|
|
51
|
+
}
|
|
52
|
+
const ipFamily = family ?? isIP(normalized);
|
|
53
|
+
if (ipFamily === 4) {
|
|
54
|
+
return isPrivateOrLoopbackIPv4(normalized);
|
|
55
|
+
}
|
|
56
|
+
if (ipFamily === 6) {
|
|
57
|
+
if (normalized === '::' || normalized === '::1') {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const [firstHextet] = normalized.split(':');
|
|
61
|
+
return (firstHextet.startsWith('fc') ||
|
|
62
|
+
firstHextet.startsWith('fd') ||
|
|
63
|
+
firstHextet.startsWith('fe8') ||
|
|
64
|
+
firstHextet.startsWith('fe9') ||
|
|
65
|
+
firstHextet.startsWith('fea') ||
|
|
66
|
+
firstHextet.startsWith('feb'));
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
async function validatePublicHost(hostname) {
|
|
27
71
|
const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
72
|
+
const isPrivatePattern = PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(bare));
|
|
73
|
+
if (isPrivatePattern) {
|
|
74
|
+
throw new TestRailValidationError(`baseUrl resolves to a private/loopback host ("${hostname}"). ` +
|
|
75
|
+
'Set allowPrivateHosts: true to allow on-premise deployments.');
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const dns = await import('node:dns/promises');
|
|
79
|
+
const lookups = await dns.lookup(bare, { all: true }).catch(() => []);
|
|
80
|
+
for (const lookup of lookups) {
|
|
81
|
+
if (lookup.address !== '' && isPrivateOrLoopbackIP(lookup.address, lookup.family)) {
|
|
82
|
+
throw new TestRailValidationError(`baseUrl resolves to a private/loopback host ("${hostname}" -> "${lookup.address}"). ` +
|
|
83
|
+
'Set allowPrivateHosts: true to allow on-premise deployments.');
|
|
84
|
+
}
|
|
32
85
|
}
|
|
33
86
|
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err instanceof TestRailValidationError)
|
|
89
|
+
throw err;
|
|
90
|
+
// DNS/import failures are ignored to avoid blocking valid public hosts
|
|
91
|
+
// when DNS is temporarily unavailable in constrained runtimes.
|
|
92
|
+
// eslint-disable-next-line no-console
|
|
93
|
+
console.warn(`Warning: DNS host validation skipped due to lookup error: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
94
|
+
}
|
|
34
95
|
}
|
|
35
96
|
const activeClients = new Set();
|
|
36
97
|
let processHandlersRegistered = false;
|
|
@@ -76,6 +137,8 @@ export class TestRailClientCore {
|
|
|
76
137
|
cacheCleanupTimer;
|
|
77
138
|
rateLimiter;
|
|
78
139
|
isDestroyed = false;
|
|
140
|
+
dnsValidationPromise;
|
|
141
|
+
dnsValidationError;
|
|
79
142
|
constructor(config) {
|
|
80
143
|
this.validateConfig(config);
|
|
81
144
|
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
|
@@ -98,6 +161,21 @@ export class TestRailClientCore {
|
|
|
98
161
|
windowMs: config.rateLimiter?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
99
162
|
requests: [],
|
|
100
163
|
};
|
|
164
|
+
// Start async DNS host validation in the background and await it in request().
|
|
165
|
+
// If DNS resolves to a private IP, mark this client so all requests fail.
|
|
166
|
+
// The synchronous regex check in validateConfig already blocks obvious private addresses.
|
|
167
|
+
if (config.allowPrivateHosts !== true) {
|
|
168
|
+
// URL already validated by validateConfig — this parse cannot throw.
|
|
169
|
+
const url = new URL(config.baseUrl);
|
|
170
|
+
this.dnsValidationPromise = validatePublicHost(url.hostname).catch((err) => {
|
|
171
|
+
if (err instanceof TestRailValidationError) {
|
|
172
|
+
this.dnsValidationError = err;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
this.dnsValidationPromise = undefined;
|
|
178
|
+
}
|
|
101
179
|
// Register this instance for automatic cleanup
|
|
102
180
|
activeClients.add(this);
|
|
103
181
|
registerProcessHandlers();
|
|
@@ -128,10 +206,16 @@ export class TestRailClientCore {
|
|
|
128
206
|
throw new TestRailValidationError('baseUrl must use HTTPS. HTTP sends credentials in cleartext. ' +
|
|
129
207
|
'Set allowInsecure: true only in isolated development environments.');
|
|
130
208
|
}
|
|
131
|
-
//
|
|
132
|
-
//
|
|
209
|
+
// Syntactic SSRF protection: reject obvious private hostnames synchronously.
|
|
210
|
+
// The async DNS check in validatePublicHost adds defence-in-depth for rebinding.
|
|
133
211
|
if (config.allowPrivateHosts !== true) {
|
|
134
|
-
|
|
212
|
+
const bare = url.hostname.startsWith('[') && url.hostname.endsWith(']')
|
|
213
|
+
? url.hostname.slice(1, -1)
|
|
214
|
+
: url.hostname;
|
|
215
|
+
if (PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(bare))) {
|
|
216
|
+
throw new TestRailValidationError(`baseUrl resolves to a private/loopback host ("${url.hostname}"). ` +
|
|
217
|
+
'Set allowPrivateHosts: true to allow on-premise deployments.');
|
|
218
|
+
}
|
|
135
219
|
}
|
|
136
220
|
}
|
|
137
221
|
catch (err) {
|
|
@@ -228,7 +312,10 @@ export class TestRailClientCore {
|
|
|
228
312
|
}
|
|
229
313
|
}
|
|
230
314
|
const waitTime = oldestRequest + this.rateLimiter.windowMs - now;
|
|
231
|
-
throw new TestRailApiError(
|
|
315
|
+
throw new TestRailApiError(429, 'Too Many Requests', {
|
|
316
|
+
message: `Rate limit exceeded. Please wait ${Math.ceil(waitTime / 1000)} seconds before making another request.`,
|
|
317
|
+
waitTimeMs: waitTime,
|
|
318
|
+
});
|
|
232
319
|
}
|
|
233
320
|
this.rateLimiter.requests.push(now);
|
|
234
321
|
}
|
|
@@ -385,6 +472,7 @@ export class TestRailClientCore {
|
|
|
385
472
|
if (this.isDestroyed) {
|
|
386
473
|
throw new Error('Cannot use TestRailClient after destroy() has been called');
|
|
387
474
|
}
|
|
475
|
+
await this.awaitDnsValidation();
|
|
388
476
|
// Check cache for GET requests
|
|
389
477
|
if (method === 'GET' && !skipCache) {
|
|
390
478
|
const cacheKey = `${method}:${endpoint}`;
|
|
@@ -428,7 +516,7 @@ export class TestRailClientCore {
|
|
|
428
516
|
// or secret values. Keep it in the structured `response` field for
|
|
429
517
|
// programmatic inspection but do not embed it in the message string,
|
|
430
518
|
// which callers commonly pass to loggers.
|
|
431
|
-
throw new TestRailApiError(
|
|
519
|
+
throw new TestRailApiError(response.status, response.statusText, errorText);
|
|
432
520
|
}
|
|
433
521
|
// Invalidate cache after mutating requests to avoid stale GET results.
|
|
434
522
|
// Done before the empty-body check so empty responses (e.g. delete endpoints)
|
|
@@ -450,7 +538,7 @@ export class TestRailClientCore {
|
|
|
450
538
|
return result;
|
|
451
539
|
}
|
|
452
540
|
catch {
|
|
453
|
-
throw new TestRailApiError('Invalid JSON response from TestRail API');
|
|
541
|
+
throw new TestRailApiError(0, 'Invalid JSON response from TestRail API');
|
|
454
542
|
}
|
|
455
543
|
}
|
|
456
544
|
catch (error) {
|
|
@@ -461,14 +549,14 @@ export class TestRailClientCore {
|
|
|
461
549
|
const isAbortError = error.name === 'AbortError';
|
|
462
550
|
// Don't retry timeout errors to avoid excessive wait times
|
|
463
551
|
if (isAbortError) {
|
|
464
|
-
throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
|
|
552
|
+
throw new TestRailApiError(408, `Request timeout after ${this.timeout}ms`);
|
|
465
553
|
}
|
|
466
554
|
// Retry on network errors up to the maximum number of retries
|
|
467
555
|
if (retryCount < this.maxRetries) {
|
|
468
556
|
await sleep(this.getRetryDelay(retryCount));
|
|
469
557
|
return this.request(method, endpoint, data, retryCount + 1, skipCache);
|
|
470
558
|
}
|
|
471
|
-
throw new TestRailApiError(`Network error: ${error.message}`,
|
|
559
|
+
throw new TestRailApiError(0, `Network error: ${error.message}`, error.message);
|
|
472
560
|
}
|
|
473
561
|
}
|
|
474
562
|
/**
|
|
@@ -486,6 +574,7 @@ export class TestRailClientCore {
|
|
|
486
574
|
if (this.isDestroyed) {
|
|
487
575
|
throw new Error('Cannot use TestRailClient after destroy() has been called');
|
|
488
576
|
}
|
|
577
|
+
await this.awaitDnsValidation();
|
|
489
578
|
this.checkRateLimit();
|
|
490
579
|
const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
|
|
491
580
|
const formData = new globalThis.FormData();
|
|
@@ -514,7 +603,7 @@ export class TestRailClientCore {
|
|
|
514
603
|
clearTimeout(timeoutId);
|
|
515
604
|
if (!response.ok) {
|
|
516
605
|
const errorText = await response.text().catch(() => 'Unknown error');
|
|
517
|
-
throw new TestRailApiError(
|
|
606
|
+
throw new TestRailApiError(response.status, response.statusText, errorText);
|
|
518
607
|
}
|
|
519
608
|
// Invalidate cache after upload
|
|
520
609
|
this.clearCache();
|
|
@@ -526,7 +615,7 @@ export class TestRailClientCore {
|
|
|
526
615
|
return JSON.parse(responseText);
|
|
527
616
|
}
|
|
528
617
|
catch {
|
|
529
|
-
throw new TestRailApiError('Invalid JSON response from TestRail API');
|
|
618
|
+
throw new TestRailApiError(0, 'Invalid JSON response from TestRail API');
|
|
530
619
|
}
|
|
531
620
|
}
|
|
532
621
|
catch (error) {
|
|
@@ -536,9 +625,9 @@ export class TestRailClientCore {
|
|
|
536
625
|
}
|
|
537
626
|
const isAbortError = error.name === 'AbortError';
|
|
538
627
|
if (isAbortError) {
|
|
539
|
-
throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
|
|
628
|
+
throw new TestRailApiError(408, `Request timeout after ${this.timeout}ms`);
|
|
540
629
|
}
|
|
541
|
-
throw new TestRailApiError(`Network error: ${error.message}`,
|
|
630
|
+
throw new TestRailApiError(0, `Network error: ${error.message}`, error.message);
|
|
542
631
|
}
|
|
543
632
|
}
|
|
544
633
|
/**
|
|
@@ -553,6 +642,7 @@ export class TestRailClientCore {
|
|
|
553
642
|
if (this.isDestroyed) {
|
|
554
643
|
throw new Error('Cannot use TestRailClient after destroy() has been called');
|
|
555
644
|
}
|
|
645
|
+
await this.awaitDnsValidation();
|
|
556
646
|
this.checkRateLimit();
|
|
557
647
|
const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
|
|
558
648
|
const controller = new AbortController();
|
|
@@ -577,7 +667,7 @@ export class TestRailClientCore {
|
|
|
577
667
|
await sleep(delay);
|
|
578
668
|
return this.requestBinary(endpoint, retryCount + 1);
|
|
579
669
|
}
|
|
580
|
-
throw new TestRailApiError(
|
|
670
|
+
throw new TestRailApiError(response.status, response.statusText, errorText);
|
|
581
671
|
}
|
|
582
672
|
return response.arrayBuffer();
|
|
583
673
|
}
|
|
@@ -588,13 +678,49 @@ export class TestRailClientCore {
|
|
|
588
678
|
}
|
|
589
679
|
const isAbortError = error.name === 'AbortError';
|
|
590
680
|
if (isAbortError) {
|
|
591
|
-
throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
|
|
681
|
+
throw new TestRailApiError(408, `Request timeout after ${this.timeout}ms`);
|
|
592
682
|
}
|
|
593
683
|
if (retryCount < this.maxRetries) {
|
|
594
684
|
await sleep(this.getRetryDelay(retryCount));
|
|
595
685
|
return this.requestBinary(endpoint, retryCount + 1);
|
|
596
686
|
}
|
|
597
|
-
throw new TestRailApiError(`Network error: ${error.message}`,
|
|
687
|
+
throw new TestRailApiError(0, `Network error: ${error.message}`, error.message);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async awaitDnsValidation() {
|
|
691
|
+
if (this.dnsValidationPromise !== undefined) {
|
|
692
|
+
let timeoutId;
|
|
693
|
+
try {
|
|
694
|
+
await Promise.race([
|
|
695
|
+
this.dnsValidationPromise,
|
|
696
|
+
new Promise((resolve) => {
|
|
697
|
+
timeoutId = setTimeout(resolve, DEFAULT_DNS_VALIDATION_MAX_WAIT_MS);
|
|
698
|
+
}),
|
|
699
|
+
]);
|
|
700
|
+
}
|
|
701
|
+
finally {
|
|
702
|
+
if (timeoutId !== undefined) {
|
|
703
|
+
clearTimeout(timeoutId);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (this.dnsValidationError !== undefined) {
|
|
708
|
+
throw this.dnsValidationError;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Validates `data` against `schema` and returns it typed as `T`.
|
|
713
|
+
* @throws {TestRailValidationError} When data does not conform to schema
|
|
714
|
+
*/
|
|
715
|
+
parse(schema, data) {
|
|
716
|
+
try {
|
|
717
|
+
return schema.parse(data);
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
if (err instanceof ZodError) {
|
|
721
|
+
throw handleZodError(err);
|
|
722
|
+
}
|
|
723
|
+
throw err;
|
|
598
724
|
}
|
|
599
725
|
}
|
|
600
726
|
}
|