@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
package/dist/cli.js CHANGED
@@ -1,268 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import { parseArgs } from 'node:util';
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
@@ -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
- protected validateId(id: number, name: string): void;
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
- protected validateEntryId(entryId: string): void;
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
- protected validatePaginationParams(limit?: number, offset?: number): void;
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
- protected buildEndpoint(base: string, params?: Record<string, string | number | undefined>): string;
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
- protected request<T>(method: string, endpoint: string, data?: unknown, retryCount?: number, skipCache?: boolean): Promise<T>;
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
- protected requestMultipart<T>(endpoint: string, file: globalThis.Blob | Uint8Array | globalThis.File, filename: string): Promise<T>;
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
- protected requestBinary(endpoint: string, retryCount?: number): Promise<ArrayBuffer>;
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
@@ -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
- // NOTE: This check is purely syntactic (regex on the hostname string). It does NOT
10
- // resolve DNS, so a public-looking hostname that resolves to a private IP, or a
11
- // DNS-rebinding attack, can still bypass this protection. For full SSRF prevention
12
- // use a network-level egress filter or a proxy that validates resolved addresses.
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 validatePublicHost(hostname) {
26
- // Strip enclosing brackets from IPv6 literals (e.g. "[::1]" → "::1")
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
- for (const pattern of PRIVATE_HOST_PATTERNS) {
29
- if (pattern.test(bare)) {
30
- throw new TestRailValidationError(`baseUrl resolves to a private/loopback host ("${hostname}"). ` +
31
- 'Set allowPrivateHosts: true to allow on-premise deployments.');
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
- // Block SSRF targets (loopback, link-local, private ranges) unless
132
- // the caller explicitly opts in for on-premise/private-network deployments.
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
- validatePublicHost(url.hostname);
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(`Rate limit exceeded. Please wait ${Math.ceil(waitTime / 1000)} seconds before making another request.`);
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(`TestRail API error: ${response.status} ${response.statusText}`, response.status, response.statusText, errorText);
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}`, undefined, undefined, 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(`TestRail API error: ${response.status} ${response.statusText}`, response.status, response.statusText, errorText);
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}`, undefined, undefined, 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(`TestRail API error: ${response.status} ${response.statusText}`, response.status, response.statusText, errorText);
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}`, undefined, undefined, 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
  }