@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/src/client-core.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { TestRailConfig, CacheEntry } from './types.js';
|
|
2
2
|
import { base64Encode, sleep } from './utils.js';
|
|
3
|
-
import { TestRailApiError, TestRailValidationError } from './errors.js';
|
|
3
|
+
import { TestRailApiError, TestRailValidationError, handleZodError } from './errors.js';
|
|
4
4
|
import pkg from '../package.json' with { type: 'json' };
|
|
5
|
+
import { isIP } from 'node:net';
|
|
6
|
+
import { ZodError, type ZodType } from 'zod';
|
|
5
7
|
|
|
6
8
|
const USER_AGENT = `${pkg.description}/${pkg.version}`;
|
|
7
9
|
import {
|
|
@@ -15,15 +17,16 @@ import {
|
|
|
15
17
|
DEFAULT_MAX_CACHE_SIZE,
|
|
16
18
|
DEFAULT_RATE_LIMIT_MAX_REQUESTS,
|
|
17
19
|
DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
20
|
+
DEFAULT_DNS_VALIDATION_MAX_WAIT_MS,
|
|
18
21
|
} from './constants.js';
|
|
19
22
|
|
|
20
23
|
// Reject loopback, link-local, and private-range hosts to prevent SSRF.
|
|
21
24
|
// All requests carry a full Authorization header, making the client a credentialed
|
|
22
25
|
// probe for internal services when baseUrl is attacker-controlled.
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
26
|
+
// Protection combines syntactic checks (regex on hostname string) with DNS resolution:
|
|
27
|
+
// validatePublicHost() resolves the hostname and checks resulting IPs. A DNS validation
|
|
28
|
+
// promise is awaited briefly before each request. This blocks obvious private-host
|
|
29
|
+
// resolutions but does not fully eliminate rebinding after initial validation.
|
|
27
30
|
const PRIVATE_HOST_PATTERNS: RegExp[] = [
|
|
28
31
|
/^localhost\.?$/i, // matches "localhost" with or without trailing dot
|
|
29
32
|
/^127\./,
|
|
@@ -37,16 +40,89 @@ const PRIVATE_HOST_PATTERNS: RegExp[] = [
|
|
|
37
40
|
/^0\./,
|
|
38
41
|
];
|
|
39
42
|
|
|
40
|
-
function
|
|
41
|
-
|
|
43
|
+
function isPrivateOrLoopbackIPv4(ip: string): boolean {
|
|
44
|
+
const octetParts = ip.split('.');
|
|
45
|
+
if (octetParts.length !== 4 || octetParts.some((part) => !/^\d+$/.test(part))) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const octets = octetParts.map((part) => Number.parseInt(part, 10));
|
|
50
|
+
if (octets.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const [o0, o1] = octets as [number, number, ...number[]];
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
o0 === 0 ||
|
|
57
|
+
o0 === 10 ||
|
|
58
|
+
o0 === 127 ||
|
|
59
|
+
(o0 === 169 && o1 === 254) ||
|
|
60
|
+
(o0 === 172 && o1 >= 16 && o1 <= 31) ||
|
|
61
|
+
(o0 === 192 && o1 === 168)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isPrivateOrLoopbackIP(ip: string, family?: number): boolean {
|
|
66
|
+
const [normalized] = ip.toLowerCase().split('%') as [string, ...string[]];
|
|
67
|
+
// Handle IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1).
|
|
68
|
+
const mappedIPv4 = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
69
|
+
const mappedAddress = mappedIPv4?.[1];
|
|
70
|
+
if (mappedAddress !== undefined) {
|
|
71
|
+
return isPrivateOrLoopbackIPv4(mappedAddress);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ipFamily = family ?? isIP(normalized);
|
|
75
|
+
if (ipFamily === 4) {
|
|
76
|
+
return isPrivateOrLoopbackIPv4(normalized);
|
|
77
|
+
}
|
|
78
|
+
if (ipFamily === 6) {
|
|
79
|
+
if (normalized === '::' || normalized === '::1') {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const [firstHextet] = normalized.split(':') as [string, ...string[]];
|
|
84
|
+
return (
|
|
85
|
+
firstHextet.startsWith('fc') ||
|
|
86
|
+
firstHextet.startsWith('fd') ||
|
|
87
|
+
firstHextet.startsWith('fe8') ||
|
|
88
|
+
firstHextet.startsWith('fe9') ||
|
|
89
|
+
firstHextet.startsWith('fea') ||
|
|
90
|
+
firstHextet.startsWith('feb')
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function validatePublicHost(hostname: string): Promise<void> {
|
|
42
98
|
const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
99
|
+
const isPrivatePattern = PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(bare));
|
|
100
|
+
if (isPrivatePattern) {
|
|
101
|
+
throw new TestRailValidationError(
|
|
102
|
+
`baseUrl resolves to a private/loopback host ("${hostname}"). ` +
|
|
103
|
+
'Set allowPrivateHosts: true to allow on-premise deployments.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const dns = await import('node:dns/promises');
|
|
109
|
+
const lookups = await dns.lookup(bare, { all: true }).catch(() => []);
|
|
110
|
+
for (const lookup of lookups) {
|
|
111
|
+
if (lookup.address !== '' && isPrivateOrLoopbackIP(lookup.address, lookup.family)) {
|
|
112
|
+
throw new TestRailValidationError(
|
|
113
|
+
`baseUrl resolves to a private/loopback host ("${hostname}" -> "${lookup.address}"). ` +
|
|
114
|
+
'Set allowPrivateHosts: true to allow on-premise deployments.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
49
117
|
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err instanceof TestRailValidationError) throw err;
|
|
120
|
+
// DNS/import failures are ignored to avoid blocking valid public hosts
|
|
121
|
+
// when DNS is temporarily unavailable in constrained runtimes.
|
|
122
|
+
// eslint-disable-next-line no-console
|
|
123
|
+
console.warn(
|
|
124
|
+
`Warning: DNS host validation skipped due to lookup error: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
|
125
|
+
);
|
|
50
126
|
}
|
|
51
127
|
}
|
|
52
128
|
|
|
@@ -98,6 +174,8 @@ export class TestRailClientCore {
|
|
|
98
174
|
private cacheCleanupTimer: ReturnType<typeof setInterval> | undefined;
|
|
99
175
|
private readonly rateLimiter: { maxRequests: number; windowMs: number; requests: number[] };
|
|
100
176
|
private isDestroyed = false;
|
|
177
|
+
private readonly dnsValidationPromise: Promise<void> | undefined;
|
|
178
|
+
private dnsValidationError: TestRailValidationError | undefined;
|
|
101
179
|
|
|
102
180
|
constructor(config: TestRailConfig) {
|
|
103
181
|
this.validateConfig(config);
|
|
@@ -124,6 +202,21 @@ export class TestRailClientCore {
|
|
|
124
202
|
requests: [],
|
|
125
203
|
};
|
|
126
204
|
|
|
205
|
+
// Start async DNS host validation in the background and await it in request().
|
|
206
|
+
// If DNS resolves to a private IP, mark this client so all requests fail.
|
|
207
|
+
// The synchronous regex check in validateConfig already blocks obvious private addresses.
|
|
208
|
+
if (config.allowPrivateHosts !== true) {
|
|
209
|
+
// URL already validated by validateConfig — this parse cannot throw.
|
|
210
|
+
const url = new URL(config.baseUrl);
|
|
211
|
+
this.dnsValidationPromise = validatePublicHost(url.hostname).catch((err: unknown) => {
|
|
212
|
+
if (err instanceof TestRailValidationError) {
|
|
213
|
+
this.dnsValidationError = err;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
this.dnsValidationPromise = undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
127
220
|
// Register this instance for automatic cleanup
|
|
128
221
|
activeClients.add(this);
|
|
129
222
|
registerProcessHandlers();
|
|
@@ -163,10 +256,19 @@ export class TestRailClientCore {
|
|
|
163
256
|
);
|
|
164
257
|
}
|
|
165
258
|
|
|
166
|
-
//
|
|
167
|
-
//
|
|
259
|
+
// Syntactic SSRF protection: reject obvious private hostnames synchronously.
|
|
260
|
+
// The async DNS check in validatePublicHost adds defence-in-depth for rebinding.
|
|
168
261
|
if (config.allowPrivateHosts !== true) {
|
|
169
|
-
|
|
262
|
+
const bare =
|
|
263
|
+
url.hostname.startsWith('[') && url.hostname.endsWith(']')
|
|
264
|
+
? url.hostname.slice(1, -1)
|
|
265
|
+
: url.hostname;
|
|
266
|
+
if (PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(bare))) {
|
|
267
|
+
throw new TestRailValidationError(
|
|
268
|
+
`baseUrl resolves to a private/loopback host ("${url.hostname}"). ` +
|
|
269
|
+
'Set allowPrivateHosts: true to allow on-premise deployments.',
|
|
270
|
+
);
|
|
271
|
+
}
|
|
170
272
|
}
|
|
171
273
|
} catch (err) {
|
|
172
274
|
if (err instanceof TestRailValidationError) {
|
|
@@ -281,9 +383,10 @@ export class TestRailClientCore {
|
|
|
281
383
|
}
|
|
282
384
|
}
|
|
283
385
|
const waitTime = oldestRequest + this.rateLimiter.windowMs - now;
|
|
284
|
-
throw new TestRailApiError(
|
|
285
|
-
`Rate limit exceeded. Please wait ${Math.ceil(waitTime / 1000)} seconds before making another request.`,
|
|
286
|
-
|
|
386
|
+
throw new TestRailApiError(429, 'Too Many Requests', {
|
|
387
|
+
message: `Rate limit exceeded. Please wait ${Math.ceil(waitTime / 1000)} seconds before making another request.`,
|
|
388
|
+
waitTimeMs: waitTime,
|
|
389
|
+
});
|
|
287
390
|
}
|
|
288
391
|
|
|
289
392
|
this.rateLimiter.requests.push(now);
|
|
@@ -293,7 +396,7 @@ export class TestRailClientCore {
|
|
|
293
396
|
* Validates that an ID is a positive integer.
|
|
294
397
|
* @throws {TestRailValidationError} When ID is invalid
|
|
295
398
|
*/
|
|
296
|
-
|
|
399
|
+
public validateId(id: number, name: string): void {
|
|
297
400
|
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) {
|
|
298
401
|
throw new TestRailValidationError(`${name} must be a positive integer`);
|
|
299
402
|
}
|
|
@@ -303,7 +406,7 @@ export class TestRailClientCore {
|
|
|
303
406
|
* Validates that a string entry ID is non-empty.
|
|
304
407
|
* @throws {TestRailValidationError} When entryId is not a non-empty string
|
|
305
408
|
*/
|
|
306
|
-
|
|
409
|
+
public validateEntryId(entryId: string): void {
|
|
307
410
|
if (typeof entryId !== 'string' || entryId.trim() === '') {
|
|
308
411
|
throw new TestRailValidationError('entryId must be a non-empty string');
|
|
309
412
|
}
|
|
@@ -313,7 +416,7 @@ export class TestRailClientCore {
|
|
|
313
416
|
* Validates optional pagination parameters.
|
|
314
417
|
* @throws {TestRailValidationError} When limit is not a positive integer or offset is not a non-negative integer
|
|
315
418
|
*/
|
|
316
|
-
|
|
419
|
+
public validatePaginationParams(limit?: number, offset?: number): void {
|
|
317
420
|
if (limit !== undefined) {
|
|
318
421
|
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
|
|
319
422
|
throw new TestRailValidationError('limit must be a positive integer');
|
|
@@ -332,7 +435,7 @@ export class TestRailClientCore {
|
|
|
332
435
|
* Keys and values are automatically percent-encoded via `encodeURIComponent`.
|
|
333
436
|
* Do NOT pre-encode values before passing them; doing so will cause double-encoding.
|
|
334
437
|
*/
|
|
335
|
-
|
|
438
|
+
public buildEndpoint(base: string, params: Record<string, string | number | undefined> = {}): string {
|
|
336
439
|
const parts: string[] = [];
|
|
337
440
|
for (const [key, value] of Object.entries(params)) {
|
|
338
441
|
if (value !== undefined) {
|
|
@@ -459,7 +562,7 @@ export class TestRailClientCore {
|
|
|
459
562
|
* @throws {TestRailApiError} When the API request fails or network error occurs
|
|
460
563
|
* @throws {Error} When called after `destroy()`
|
|
461
564
|
*/
|
|
462
|
-
|
|
565
|
+
public async request<T>(
|
|
463
566
|
method: string,
|
|
464
567
|
endpoint: string,
|
|
465
568
|
data?: unknown,
|
|
@@ -471,6 +574,8 @@ export class TestRailClientCore {
|
|
|
471
574
|
throw new Error('Cannot use TestRailClient after destroy() has been called');
|
|
472
575
|
}
|
|
473
576
|
|
|
577
|
+
await this.awaitDnsValidation();
|
|
578
|
+
|
|
474
579
|
// Check cache for GET requests
|
|
475
580
|
if (method === 'GET' && !skipCache) {
|
|
476
581
|
const cacheKey = `${method}:${endpoint}`;
|
|
@@ -523,12 +628,7 @@ export class TestRailClientCore {
|
|
|
523
628
|
// or secret values. Keep it in the structured `response` field for
|
|
524
629
|
// programmatic inspection but do not embed it in the message string,
|
|
525
630
|
// which callers commonly pass to loggers.
|
|
526
|
-
throw new TestRailApiError(
|
|
527
|
-
`TestRail API error: ${response.status} ${response.statusText}`,
|
|
528
|
-
response.status,
|
|
529
|
-
response.statusText,
|
|
530
|
-
errorText,
|
|
531
|
-
);
|
|
631
|
+
throw new TestRailApiError(response.status, response.statusText, errorText);
|
|
532
632
|
}
|
|
533
633
|
|
|
534
634
|
// Invalidate cache after mutating requests to avoid stale GET results.
|
|
@@ -554,7 +654,7 @@ export class TestRailClientCore {
|
|
|
554
654
|
|
|
555
655
|
return result;
|
|
556
656
|
} catch {
|
|
557
|
-
throw new TestRailApiError('Invalid JSON response from TestRail API');
|
|
657
|
+
throw new TestRailApiError(0, 'Invalid JSON response from TestRail API');
|
|
558
658
|
}
|
|
559
659
|
} catch (error) {
|
|
560
660
|
clearTimeout(timeoutId);
|
|
@@ -567,7 +667,7 @@ export class TestRailClientCore {
|
|
|
567
667
|
|
|
568
668
|
// Don't retry timeout errors to avoid excessive wait times
|
|
569
669
|
if (isAbortError) {
|
|
570
|
-
throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
|
|
670
|
+
throw new TestRailApiError(408, `Request timeout after ${this.timeout}ms`);
|
|
571
671
|
}
|
|
572
672
|
|
|
573
673
|
// Retry on network errors up to the maximum number of retries
|
|
@@ -576,12 +676,7 @@ export class TestRailClientCore {
|
|
|
576
676
|
return this.request<T>(method, endpoint, data, retryCount + 1, skipCache);
|
|
577
677
|
}
|
|
578
678
|
|
|
579
|
-
throw new TestRailApiError(
|
|
580
|
-
`Network error: ${(error as Error).message}`,
|
|
581
|
-
undefined,
|
|
582
|
-
undefined,
|
|
583
|
-
(error as Error).message,
|
|
584
|
-
);
|
|
679
|
+
throw new TestRailApiError(0, `Network error: ${(error as Error).message}`, (error as Error).message);
|
|
585
680
|
}
|
|
586
681
|
}
|
|
587
682
|
|
|
@@ -596,7 +691,7 @@ export class TestRailClientCore {
|
|
|
596
691
|
* @throws {TestRailApiError} When the API request fails or network error occurs
|
|
597
692
|
* @throws {Error} When called after `destroy()`
|
|
598
693
|
*/
|
|
599
|
-
|
|
694
|
+
public async requestMultipart<T>(
|
|
600
695
|
endpoint: string,
|
|
601
696
|
file: globalThis.Blob | Uint8Array | globalThis.File,
|
|
602
697
|
filename: string,
|
|
@@ -605,6 +700,8 @@ export class TestRailClientCore {
|
|
|
605
700
|
throw new Error('Cannot use TestRailClient after destroy() has been called');
|
|
606
701
|
}
|
|
607
702
|
|
|
703
|
+
await this.awaitDnsValidation();
|
|
704
|
+
|
|
608
705
|
this.checkRateLimit();
|
|
609
706
|
|
|
610
707
|
const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
|
|
@@ -638,12 +735,7 @@ export class TestRailClientCore {
|
|
|
638
735
|
|
|
639
736
|
if (!response.ok) {
|
|
640
737
|
const errorText = await response.text().catch(() => 'Unknown error');
|
|
641
|
-
throw new TestRailApiError(
|
|
642
|
-
`TestRail API error: ${response.status} ${response.statusText}`,
|
|
643
|
-
response.status,
|
|
644
|
-
response.statusText,
|
|
645
|
-
errorText,
|
|
646
|
-
);
|
|
738
|
+
throw new TestRailApiError(response.status, response.statusText, errorText);
|
|
647
739
|
}
|
|
648
740
|
|
|
649
741
|
// Invalidate cache after upload
|
|
@@ -657,7 +749,7 @@ export class TestRailClientCore {
|
|
|
657
749
|
try {
|
|
658
750
|
return JSON.parse(responseText) as T;
|
|
659
751
|
} catch {
|
|
660
|
-
throw new TestRailApiError('Invalid JSON response from TestRail API');
|
|
752
|
+
throw new TestRailApiError(0, 'Invalid JSON response from TestRail API');
|
|
661
753
|
}
|
|
662
754
|
} catch (error) {
|
|
663
755
|
clearTimeout(timeoutId);
|
|
@@ -668,15 +760,10 @@ export class TestRailClientCore {
|
|
|
668
760
|
|
|
669
761
|
const isAbortError = (error as Error).name === 'AbortError';
|
|
670
762
|
if (isAbortError) {
|
|
671
|
-
throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
|
|
763
|
+
throw new TestRailApiError(408, `Request timeout after ${this.timeout}ms`);
|
|
672
764
|
}
|
|
673
765
|
|
|
674
|
-
throw new TestRailApiError(
|
|
675
|
-
`Network error: ${(error as Error).message}`,
|
|
676
|
-
undefined,
|
|
677
|
-
undefined,
|
|
678
|
-
(error as Error).message,
|
|
679
|
-
);
|
|
766
|
+
throw new TestRailApiError(0, `Network error: ${(error as Error).message}`, (error as Error).message);
|
|
680
767
|
}
|
|
681
768
|
}
|
|
682
769
|
|
|
@@ -688,11 +775,13 @@ export class TestRailClientCore {
|
|
|
688
775
|
* @throws {TestRailApiError} When the API request fails or network error occurs
|
|
689
776
|
* @throws {Error} When called after `destroy()`
|
|
690
777
|
*/
|
|
691
|
-
|
|
778
|
+
public async requestBinary(endpoint: string, retryCount = 0): Promise<ArrayBuffer> {
|
|
692
779
|
if (this.isDestroyed) {
|
|
693
780
|
throw new Error('Cannot use TestRailClient after destroy() has been called');
|
|
694
781
|
}
|
|
695
782
|
|
|
783
|
+
await this.awaitDnsValidation();
|
|
784
|
+
|
|
696
785
|
this.checkRateLimit();
|
|
697
786
|
|
|
698
787
|
const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
|
|
@@ -724,12 +813,7 @@ export class TestRailClientCore {
|
|
|
724
813
|
return this.requestBinary(endpoint, retryCount + 1);
|
|
725
814
|
}
|
|
726
815
|
|
|
727
|
-
throw new TestRailApiError(
|
|
728
|
-
`TestRail API error: ${response.status} ${response.statusText}`,
|
|
729
|
-
response.status,
|
|
730
|
-
response.statusText,
|
|
731
|
-
errorText,
|
|
732
|
-
);
|
|
816
|
+
throw new TestRailApiError(response.status, response.statusText, errorText);
|
|
733
817
|
}
|
|
734
818
|
|
|
735
819
|
return response.arrayBuffer();
|
|
@@ -742,7 +826,7 @@ export class TestRailClientCore {
|
|
|
742
826
|
|
|
743
827
|
const isAbortError = (error as Error).name === 'AbortError';
|
|
744
828
|
if (isAbortError) {
|
|
745
|
-
throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
|
|
829
|
+
throw new TestRailApiError(408, `Request timeout after ${this.timeout}ms`);
|
|
746
830
|
}
|
|
747
831
|
|
|
748
832
|
if (retryCount < this.maxRetries) {
|
|
@@ -750,12 +834,44 @@ export class TestRailClientCore {
|
|
|
750
834
|
return this.requestBinary(endpoint, retryCount + 1);
|
|
751
835
|
}
|
|
752
836
|
|
|
753
|
-
throw new TestRailApiError(
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
837
|
+
throw new TestRailApiError(0, `Network error: ${(error as Error).message}`, (error as Error).message);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
private async awaitDnsValidation(): Promise<void> {
|
|
842
|
+
if (this.dnsValidationPromise !== undefined) {
|
|
843
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
844
|
+
try {
|
|
845
|
+
await Promise.race([
|
|
846
|
+
this.dnsValidationPromise,
|
|
847
|
+
new Promise<void>((resolve) => {
|
|
848
|
+
timeoutId = setTimeout(resolve, DEFAULT_DNS_VALIDATION_MAX_WAIT_MS);
|
|
849
|
+
}),
|
|
850
|
+
]);
|
|
851
|
+
} finally {
|
|
852
|
+
if (timeoutId !== undefined) {
|
|
853
|
+
clearTimeout(timeoutId);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (this.dnsValidationError !== undefined) {
|
|
859
|
+
throw this.dnsValidationError;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Validates `data` against `schema` and returns it typed as `T`.
|
|
865
|
+
* @throws {TestRailValidationError} When data does not conform to schema
|
|
866
|
+
*/
|
|
867
|
+
public parse<T>(schema: ZodType, data: unknown): T {
|
|
868
|
+
try {
|
|
869
|
+
return schema.parse(data) as T;
|
|
870
|
+
} catch (err) {
|
|
871
|
+
if (err instanceof ZodError) {
|
|
872
|
+
throw handleZodError(err);
|
|
873
|
+
}
|
|
874
|
+
throw err;
|
|
759
875
|
}
|
|
760
876
|
}
|
|
761
877
|
}
|