@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.
Files changed (137) hide show
  1. package/README.md +22 -0
  2. package/dist/cli/auth.d.ts +21 -0
  3. package/dist/cli/auth.js +16 -0
  4. package/dist/cli/body.d.ts +42 -0
  5. package/dist/cli/body.js +89 -0
  6. package/dist/cli/dispatch.d.ts +16 -0
  7. package/dist/cli/dispatch.js +87 -0
  8. package/dist/cli/handler-context.d.ts +43 -0
  9. package/dist/cli/handler-context.js +2 -0
  10. package/dist/cli/handlers/case-write.d.ts +4 -0
  11. package/dist/cli/handlers/case-write.js +26 -0
  12. package/dist/cli/handlers/case.d.ts +4 -0
  13. package/dist/cli/handlers/case.js +11 -0
  14. package/dist/cli/handlers/milestone.d.ts +4 -0
  15. package/dist/cli/handlers/milestone.js +15 -0
  16. package/dist/cli/handlers/project.d.ts +4 -0
  17. package/dist/cli/handlers/project.js +11 -0
  18. package/dist/cli/handlers/result-write.d.ts +4 -0
  19. package/dist/cli/handlers/result-write.js +40 -0
  20. package/dist/cli/handlers/result.d.ts +3 -0
  21. package/dist/cli/handlers/result.js +11 -0
  22. package/dist/cli/handlers/run-write.d.ts +10 -0
  23. package/dist/cli/handlers/run-write.js +29 -0
  24. package/dist/cli/handlers/run.d.ts +4 -0
  25. package/dist/cli/handlers/run.js +15 -0
  26. package/dist/cli/handlers/suite.d.ts +4 -0
  27. package/dist/cli/handlers/suite.js +10 -0
  28. package/dist/cli/handlers/user.d.ts +4 -0
  29. package/dist/cli/handlers/user.js +11 -0
  30. package/dist/cli/ids.d.ts +6 -0
  31. package/dist/cli/ids.js +20 -0
  32. package/dist/cli/index.d.ts +3 -0
  33. package/dist/cli/index.js +198 -0
  34. package/dist/cli/install-skill.d.ts +35 -0
  35. package/dist/cli/install-skill.js +71 -0
  36. package/dist/cli/metadata.d.ts +37 -0
  37. package/dist/cli/metadata.js +151 -0
  38. package/dist/cli/output.d.ts +28 -0
  39. package/dist/cli/output.js +84 -0
  40. package/dist/cli.d.ts +1 -1
  41. package/dist/cli.js +1 -266
  42. package/dist/client-core.d.ts +16 -7
  43. package/dist/client-core.js +153 -27
  44. package/dist/client.d.ts +274 -118
  45. package/dist/client.js +404 -463
  46. package/dist/constants.d.ts +1 -0
  47. package/dist/constants.js +1 -0
  48. package/dist/errors.d.ts +11 -9
  49. package/dist/errors.js +12 -8
  50. package/dist/index.d.ts +4 -2
  51. package/dist/index.js +2 -1
  52. package/dist/modules/attachments.d.ts +19 -0
  53. package/dist/modules/attachments.js +64 -0
  54. package/dist/modules/cases.d.ts +13 -0
  55. package/dist/modules/cases.js +58 -0
  56. package/dist/modules/configurations.d.ts +14 -0
  57. package/dist/modules/configurations.js +37 -0
  58. package/dist/modules/datasets.d.ts +12 -0
  59. package/dist/modules/datasets.js +28 -0
  60. package/dist/modules/metadata.d.ts +14 -0
  61. package/dist/modules/metadata.js +31 -0
  62. package/dist/modules/milestones.d.ts +12 -0
  63. package/dist/modules/milestones.js +36 -0
  64. package/dist/modules/plans.d.ts +16 -0
  65. package/dist/modules/plans.js +59 -0
  66. package/dist/modules/projects.d.ts +36 -0
  67. package/dist/modules/projects.js +55 -0
  68. package/dist/modules/reports.d.ts +9 -0
  69. package/dist/modules/reports.js +16 -0
  70. package/dist/modules/results.d.ts +14 -0
  71. package/dist/modules/results.js +69 -0
  72. package/dist/modules/runs.d.ts +14 -0
  73. package/dist/modules/runs.js +57 -0
  74. package/dist/modules/sections.d.ts +16 -0
  75. package/dist/modules/sections.js +37 -0
  76. package/dist/modules/sharedSteps.d.ts +12 -0
  77. package/dist/modules/sharedSteps.js +28 -0
  78. package/dist/modules/suites.d.ts +37 -0
  79. package/dist/modules/suites.js +54 -0
  80. package/dist/modules/tests.d.ts +9 -0
  81. package/dist/modules/tests.js +25 -0
  82. package/dist/modules/users.d.ts +18 -0
  83. package/dist/modules/users.js +62 -0
  84. package/dist/modules/variables.d.ts +11 -0
  85. package/dist/modules/variables.js +24 -0
  86. package/dist/schemas.d.ts +544 -0
  87. package/dist/schemas.js +419 -0
  88. package/dist/types.d.ts +1 -55
  89. package/dist/utils.d.ts +2 -0
  90. package/dist/utils.js +4 -0
  91. package/package.json +23 -15
  92. package/skill/SKILL.md +395 -0
  93. package/src/cli/auth.ts +37 -0
  94. package/src/cli/body.ts +100 -0
  95. package/src/cli/dispatch.ts +91 -0
  96. package/src/cli/handler-context.ts +46 -0
  97. package/src/cli/handlers/case-write.ts +26 -0
  98. package/src/cli/handlers/case.ts +13 -0
  99. package/src/cli/handlers/milestone.ts +19 -0
  100. package/src/cli/handlers/project.ts +13 -0
  101. package/src/cli/handlers/result-write.ts +40 -0
  102. package/src/cli/handlers/result.ts +14 -0
  103. package/src/cli/handlers/run-write.ts +30 -0
  104. package/src/cli/handlers/run.ts +19 -0
  105. package/src/cli/handlers/suite.ts +12 -0
  106. package/src/cli/handlers/user.ts +13 -0
  107. package/src/cli/ids.ts +20 -0
  108. package/src/cli/index.ts +224 -0
  109. package/src/cli/install-skill.ts +89 -0
  110. package/src/cli/metadata.ts +194 -0
  111. package/src/cli/output.ts +96 -0
  112. package/src/cli.ts +1 -286
  113. package/src/client-core.ts +183 -67
  114. package/src/client.ts +414 -483
  115. package/src/constants.ts +1 -0
  116. package/src/errors.ts +18 -11
  117. package/src/index.ts +50 -8
  118. package/src/modules/attachments.ts +125 -0
  119. package/src/modules/cases.ts +78 -0
  120. package/src/modules/configurations.ts +68 -0
  121. package/src/modules/datasets.ts +44 -0
  122. package/src/modules/metadata.ts +63 -0
  123. package/src/modules/milestones.ts +54 -0
  124. package/src/modules/plans.ts +89 -0
  125. package/src/modules/projects.ts +67 -0
  126. package/src/modules/reports.ts +23 -0
  127. package/src/modules/results.ts +90 -0
  128. package/src/modules/runs.ts +70 -0
  129. package/src/modules/sections.ts +55 -0
  130. package/src/modules/sharedSteps.ts +44 -0
  131. package/src/modules/suites.ts +67 -0
  132. package/src/modules/tests.ts +28 -0
  133. package/src/modules/users.ts +87 -0
  134. package/src/modules/variables.ts +36 -0
  135. package/src/schemas.ts +551 -0
  136. package/src/types.ts +11 -60
  137. package/src/utils.ts +5 -0
@@ -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
- // NOTE: This check is purely syntactic (regex on the hostname string). It does NOT
24
- // resolve DNS, so a public-looking hostname that resolves to a private IP, or a
25
- // DNS-rebinding attack, can still bypass this protection. For full SSRF prevention
26
- // use a network-level egress filter or a proxy that validates resolved addresses.
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 validatePublicHost(hostname: string): void {
41
- // Strip enclosing brackets from IPv6 literals (e.g. "[::1]" → "::1")
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
- for (const pattern of PRIVATE_HOST_PATTERNS) {
44
- if (pattern.test(bare)) {
45
- throw new TestRailValidationError(
46
- `baseUrl resolves to a private/loopback host ("${hostname}"). ` +
47
- 'Set allowPrivateHosts: true to allow on-premise deployments.',
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
- // Block SSRF targets (loopback, link-local, private ranges) unless
167
- // the caller explicitly opts in for on-premise/private-network deployments.
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
- validatePublicHost(url.hostname);
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
- protected validateId(id: number, name: string): void {
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
- protected validateEntryId(entryId: string): void {
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
- protected validatePaginationParams(limit?: number, offset?: number): void {
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
- protected buildEndpoint(base: string, params: Record<string, string | number | undefined> = {}): string {
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
- protected async request<T>(
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
- protected async requestMultipart<T>(
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
- protected async requestBinary(endpoint: string, retryCount = 0): Promise<ArrayBuffer> {
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
- `Network error: ${(error as Error).message}`,
755
- undefined,
756
- undefined,
757
- (error as Error).message,
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
  }