@dichovsky/testrail-api-client 1.0.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/LICENSE +21 -0
- package/README.md +537 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +268 -0
- package/dist/client-core.d.ts +103 -0
- package/dist/client-core.js +601 -0
- package/dist/client.d.ts +652 -0
- package/dist/client.js +1106 -0
- package/dist/constants.d.ts +12 -0
- package/dist/constants.js +14 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +29 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/types.d.ts +762 -0
- package/dist/types.js +2 -0
- package/dist/utils.d.ts +5 -0
- package/dist/utils.js +13 -0
- package/package.json +76 -0
- package/src/cli.ts +287 -0
- package/src/client-core.ts +761 -0
- package/src/client.ts +1338 -0
- package/src/constants.ts +15 -0
- package/src/errors.ts +28 -0
- package/src/index.ts +74 -0
- package/src/types.ts +853 -0
- package/src/utils.ts +15 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import { base64Encode, sleep } from './utils.js';
|
|
2
|
+
import { TestRailApiError, TestRailValidationError } from './errors.js';
|
|
3
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
4
|
+
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';
|
|
6
|
+
// Reject loopback, link-local, and private-range hosts to prevent SSRF.
|
|
7
|
+
// All requests carry a full Authorization header, making the client a credentialed
|
|
8
|
+
// 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.
|
|
13
|
+
const PRIVATE_HOST_PATTERNS = [
|
|
14
|
+
/^localhost\.?$/i, // matches "localhost" with or without trailing dot
|
|
15
|
+
/^127\./,
|
|
16
|
+
/^10\./,
|
|
17
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
18
|
+
/^192\.168\./,
|
|
19
|
+
/^169\.254\./,
|
|
20
|
+
/^::1$/,
|
|
21
|
+
/^fe80:/i, // IPv6 link-local (fe80::/10)
|
|
22
|
+
/^f[cd][0-9a-f]{2}:/i, // IPv6 unique-local (fc00::/7 covers fc** and fd**)
|
|
23
|
+
/^0\./,
|
|
24
|
+
];
|
|
25
|
+
function validatePublicHost(hostname) {
|
|
26
|
+
// Strip enclosing brackets from IPv6 literals (e.g. "[::1]" → "::1")
|
|
27
|
+
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.');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const activeClients = new Set();
|
|
36
|
+
let processHandlersRegistered = false;
|
|
37
|
+
// Synchronous-only cleanup — safe to call on process exit
|
|
38
|
+
function cleanupAllClients() {
|
|
39
|
+
for (const client of activeClients) {
|
|
40
|
+
client.destroy();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function registerProcessHandlers() {
|
|
44
|
+
if (processHandlersRegistered) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (typeof process !== 'undefined' && typeof process.on === 'function') {
|
|
48
|
+
process.on('exit', cleanupAllClients);
|
|
49
|
+
process.on('SIGINT', () => {
|
|
50
|
+
cleanupAllClients();
|
|
51
|
+
process.exit(130);
|
|
52
|
+
});
|
|
53
|
+
process.on('SIGTERM', () => {
|
|
54
|
+
cleanupAllClients();
|
|
55
|
+
process.exit(143);
|
|
56
|
+
});
|
|
57
|
+
processHandlersRegistered = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* HTTP pipeline, caching, rate limiting, retry logic, and lifecycle management.
|
|
62
|
+
* Extended by {@link TestRailClient} which adds all API endpoint methods.
|
|
63
|
+
*/
|
|
64
|
+
export class TestRailClientCore {
|
|
65
|
+
baseUrl;
|
|
66
|
+
// Declared non-readonly so it can be zeroed in destroy() to reduce
|
|
67
|
+
// the window during which the credential is recoverable from a heap dump.
|
|
68
|
+
auth;
|
|
69
|
+
timeout;
|
|
70
|
+
maxRetries;
|
|
71
|
+
enableCache;
|
|
72
|
+
cacheTtl;
|
|
73
|
+
cacheCleanupInterval;
|
|
74
|
+
maxCacheSize;
|
|
75
|
+
cache = new Map();
|
|
76
|
+
cacheCleanupTimer;
|
|
77
|
+
rateLimiter;
|
|
78
|
+
isDestroyed = false;
|
|
79
|
+
constructor(config) {
|
|
80
|
+
this.validateConfig(config);
|
|
81
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
|
82
|
+
this.auth = base64Encode(`${config.email}:${config.apiKey}`);
|
|
83
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
84
|
+
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
85
|
+
this.enableCache = config.enableCache ?? true;
|
|
86
|
+
this.cacheTtl = config.cacheTtl ?? DEFAULT_CACHE_TTL_MS;
|
|
87
|
+
this.cacheCleanupInterval = config.cacheCleanupInterval ?? DEFAULT_CACHE_CLEANUP_INTERVAL_MS;
|
|
88
|
+
// maxCacheSize=0 means unbounded and risks memory exhaustion.
|
|
89
|
+
// Warn at construction time so callers are aware of the risk.
|
|
90
|
+
if (config.maxCacheSize === 0 && (config.enableCache ?? true)) {
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn('Warning: maxCacheSize is set to 0 (unlimited). ' +
|
|
93
|
+
'This can cause unbounded memory growth. Consider setting a positive limit.');
|
|
94
|
+
}
|
|
95
|
+
this.maxCacheSize = config.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE;
|
|
96
|
+
this.rateLimiter = {
|
|
97
|
+
maxRequests: config.rateLimiter?.maxRequests ?? DEFAULT_RATE_LIMIT_MAX_REQUESTS,
|
|
98
|
+
windowMs: config.rateLimiter?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
99
|
+
requests: [],
|
|
100
|
+
};
|
|
101
|
+
// Register this instance for automatic cleanup
|
|
102
|
+
activeClients.add(this);
|
|
103
|
+
registerProcessHandlers();
|
|
104
|
+
// Start periodic cache cleanup if enabled
|
|
105
|
+
if (this.enableCache && this.cacheCleanupInterval > 0) {
|
|
106
|
+
this.startCacheCleanup();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
validateConfig(config) {
|
|
110
|
+
if (!config.baseUrl || typeof config.baseUrl !== 'string') {
|
|
111
|
+
throw new TestRailValidationError('baseUrl is required and must be a string');
|
|
112
|
+
}
|
|
113
|
+
if (!config.email || typeof config.email !== 'string') {
|
|
114
|
+
throw new TestRailValidationError('email is required and must be a string');
|
|
115
|
+
}
|
|
116
|
+
if (!config.apiKey || typeof config.apiKey !== 'string') {
|
|
117
|
+
throw new TestRailValidationError('apiKey is required and must be a string');
|
|
118
|
+
}
|
|
119
|
+
// Validate URL format
|
|
120
|
+
try {
|
|
121
|
+
const url = new URL(config.baseUrl);
|
|
122
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
123
|
+
throw new TestRailValidationError('baseUrl must use http or https protocol');
|
|
124
|
+
}
|
|
125
|
+
// Block HTTP unless explicitly opted in — Basic auth credentials are base64
|
|
126
|
+
// only; any network observer can decode them from a cleartext HTTP request.
|
|
127
|
+
if (url.protocol === 'http:' && config.allowInsecure !== true) {
|
|
128
|
+
throw new TestRailValidationError('baseUrl must use HTTPS. HTTP sends credentials in cleartext. ' +
|
|
129
|
+
'Set allowInsecure: true only in isolated development environments.');
|
|
130
|
+
}
|
|
131
|
+
// Block SSRF targets (loopback, link-local, private ranges) unless
|
|
132
|
+
// the caller explicitly opts in for on-premise/private-network deployments.
|
|
133
|
+
if (config.allowPrivateHosts !== true) {
|
|
134
|
+
validatePublicHost(url.hostname);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (err instanceof TestRailValidationError) {
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
throw new TestRailValidationError('baseUrl must be a valid URL');
|
|
142
|
+
}
|
|
143
|
+
// Validate email format
|
|
144
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
145
|
+
if (!emailRegex.test(config.email)) {
|
|
146
|
+
throw new TestRailValidationError('email must be a valid email address');
|
|
147
|
+
}
|
|
148
|
+
// Validate timeout if provided
|
|
149
|
+
if (config.timeout !== undefined) {
|
|
150
|
+
if (typeof config.timeout !== 'number' || config.timeout <= 0 || config.timeout > MAX_TIMEOUT_MS) {
|
|
151
|
+
throw new TestRailValidationError('timeout must be a positive number not exceeding 5 minutes');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Validate maxRetries if provided
|
|
155
|
+
if (config.maxRetries !== undefined) {
|
|
156
|
+
if (typeof config.maxRetries !== 'number' || config.maxRetries < 0 || config.maxRetries > 10) {
|
|
157
|
+
throw new TestRailValidationError('maxRetries must be a number between 0 and 10');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Validate maxCacheSize if provided
|
|
161
|
+
if (config.maxCacheSize !== undefined) {
|
|
162
|
+
if (typeof config.maxCacheSize !== 'number' ||
|
|
163
|
+
!Number.isInteger(config.maxCacheSize) ||
|
|
164
|
+
config.maxCacheSize < 0) {
|
|
165
|
+
throw new TestRailValidationError('maxCacheSize must be a non-negative integer');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Validate rateLimiter config values.
|
|
169
|
+
// Zero or negative maxRequests silently disables or inverts limiting.
|
|
170
|
+
// Zero or negative windowMs makes the window always empty, disabling limiting.
|
|
171
|
+
if (config.rateLimiter !== undefined) {
|
|
172
|
+
if (config.rateLimiter === null || typeof config.rateLimiter !== 'object') {
|
|
173
|
+
throw new TestRailValidationError('rateLimiter must be an object with maxRequests and windowMs');
|
|
174
|
+
}
|
|
175
|
+
if (typeof config.rateLimiter.maxRequests !== 'number' ||
|
|
176
|
+
!Number.isInteger(config.rateLimiter.maxRequests) ||
|
|
177
|
+
config.rateLimiter.maxRequests < 1) {
|
|
178
|
+
throw new TestRailValidationError('rateLimiter.maxRequests must be a positive integer');
|
|
179
|
+
}
|
|
180
|
+
if (typeof config.rateLimiter.windowMs !== 'number' ||
|
|
181
|
+
!Number.isInteger(config.rateLimiter.windowMs) ||
|
|
182
|
+
config.rateLimiter.windowMs < 1) {
|
|
183
|
+
throw new TestRailValidationError('rateLimiter.windowMs must be a positive integer');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
getRetryDelay(retryCount) {
|
|
188
|
+
return Math.min(BASE_RETRY_DELAY_MS * Math.pow(2, retryCount), MAX_RETRY_DELAY_MS);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Parses the Retry-After header value to milliseconds
|
|
192
|
+
*
|
|
193
|
+
* @param response - The HTTP response containing the Retry-After header
|
|
194
|
+
* @returns The delay in milliseconds, or null if header is absent or invalid
|
|
195
|
+
*/
|
|
196
|
+
parseRetryAfterMs(response) {
|
|
197
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
198
|
+
if (retryAfter === null || retryAfter === '') {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
// Try parsing as seconds (numeric value)
|
|
202
|
+
const seconds = parseInt(retryAfter, 10);
|
|
203
|
+
if (!isNaN(seconds) && seconds > 0) {
|
|
204
|
+
// Cap server-supplied delay to MAX_RETRY_DELAY_MS to prevent a
|
|
205
|
+
// malicious/compromised server from freezing the client indefinitely.
|
|
206
|
+
return Math.min(seconds * 1000, MAX_RETRY_DELAY_MS);
|
|
207
|
+
}
|
|
208
|
+
// Try parsing as HTTP-date format
|
|
209
|
+
const date = new Date(retryAfter);
|
|
210
|
+
if (!isNaN(date.getTime())) {
|
|
211
|
+
const delayMs = date.getTime() - Date.now();
|
|
212
|
+
// Same cap applied to HTTP-date format.
|
|
213
|
+
return delayMs > 0 ? Math.min(delayMs, MAX_RETRY_DELAY_MS) : null;
|
|
214
|
+
}
|
|
215
|
+
return null; // Invalid format
|
|
216
|
+
}
|
|
217
|
+
/** Sliding window rate limiter. @throws {TestRailApiError} when limit exceeded */
|
|
218
|
+
checkRateLimit() {
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const windowStart = now - this.rateLimiter.windowMs;
|
|
221
|
+
// Clean old requests outside the window
|
|
222
|
+
this.rateLimiter.requests = this.rateLimiter.requests.filter((time) => time > windowStart);
|
|
223
|
+
if (this.rateLimiter.requests.length >= this.rateLimiter.maxRequests) {
|
|
224
|
+
let oldestRequest = now;
|
|
225
|
+
for (const requestTime of this.rateLimiter.requests) {
|
|
226
|
+
if (requestTime < oldestRequest) {
|
|
227
|
+
oldestRequest = requestTime;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
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.`);
|
|
232
|
+
}
|
|
233
|
+
this.rateLimiter.requests.push(now);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Validates that an ID is a positive integer.
|
|
237
|
+
* @throws {TestRailValidationError} When ID is invalid
|
|
238
|
+
*/
|
|
239
|
+
validateId(id, name) {
|
|
240
|
+
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) {
|
|
241
|
+
throw new TestRailValidationError(`${name} must be a positive integer`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Validates that a string entry ID is non-empty.
|
|
246
|
+
* @throws {TestRailValidationError} When entryId is not a non-empty string
|
|
247
|
+
*/
|
|
248
|
+
validateEntryId(entryId) {
|
|
249
|
+
if (typeof entryId !== 'string' || entryId.trim() === '') {
|
|
250
|
+
throw new TestRailValidationError('entryId must be a non-empty string');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Validates optional pagination parameters.
|
|
255
|
+
* @throws {TestRailValidationError} When limit is not a positive integer or offset is not a non-negative integer
|
|
256
|
+
*/
|
|
257
|
+
validatePaginationParams(limit, offset) {
|
|
258
|
+
if (limit !== undefined) {
|
|
259
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
|
|
260
|
+
throw new TestRailValidationError('limit must be a positive integer');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (offset !== undefined) {
|
|
264
|
+
if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) {
|
|
265
|
+
throw new TestRailValidationError('offset must be a non-negative integer');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Builds a TestRail endpoint URL with optional query parameters.
|
|
271
|
+
* Appends params using `&key=value` (TestRail URL quirk — uses `&`, not `?`).
|
|
272
|
+
* Keys and values are automatically percent-encoded via `encodeURIComponent`.
|
|
273
|
+
* Do NOT pre-encode values before passing them; doing so will cause double-encoding.
|
|
274
|
+
*/
|
|
275
|
+
buildEndpoint(base, params = {}) {
|
|
276
|
+
const parts = [];
|
|
277
|
+
for (const [key, value] of Object.entries(params)) {
|
|
278
|
+
if (value !== undefined) {
|
|
279
|
+
// Encode values to prevent parameter injection via string values
|
|
280
|
+
// containing `&`, `=`, or `#`.
|
|
281
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return parts.length > 0 ? `${base}&${parts.join('&')}` : base;
|
|
285
|
+
}
|
|
286
|
+
getCachedData(cacheKey) {
|
|
287
|
+
if (!this.enableCache) {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
const entry = this.cache.get(cacheKey);
|
|
291
|
+
if (entry !== undefined && entry.expiry > Date.now()) {
|
|
292
|
+
// Move to end to mark as recently used (LRU behavior)
|
|
293
|
+
this.cache.delete(cacheKey);
|
|
294
|
+
this.cache.set(cacheKey, entry);
|
|
295
|
+
return entry.data;
|
|
296
|
+
}
|
|
297
|
+
// Clean expired entry
|
|
298
|
+
if (entry !== undefined) {
|
|
299
|
+
this.cache.delete(cacheKey);
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
setCachedData(cacheKey, data) {
|
|
304
|
+
if (!this.enableCache) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Enforce cache size limit if not zero
|
|
308
|
+
if (this.maxCacheSize > 0 && this.cache.size >= this.maxCacheSize) {
|
|
309
|
+
// Map preserves insertion order; first key is the oldest (LRU eviction)
|
|
310
|
+
// Cache is non-empty here (size >= maxCacheSize > 0), so next().value is always defined
|
|
311
|
+
const oldestKey = this.cache.keys().next().value;
|
|
312
|
+
this.cache.delete(oldestKey);
|
|
313
|
+
}
|
|
314
|
+
this.cache.set(cacheKey, {
|
|
315
|
+
data,
|
|
316
|
+
expiry: Date.now() + this.cacheTtl,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Clears the entire cache.
|
|
321
|
+
*/
|
|
322
|
+
clearCache() {
|
|
323
|
+
this.cache.clear();
|
|
324
|
+
}
|
|
325
|
+
startCacheCleanup() {
|
|
326
|
+
this.cacheCleanupTimer = setInterval(() => {
|
|
327
|
+
this.cleanupExpiredCache();
|
|
328
|
+
}, this.cacheCleanupInterval);
|
|
329
|
+
// When cache cleanup is enabled (enableCache is true and cacheCleanupInterval > 0),
|
|
330
|
+
// ensure this timer doesn't prevent process exit in Node.js; the unref check keeps
|
|
331
|
+
// compatibility with non-Node.js environments where unref may not exist.
|
|
332
|
+
this.cacheCleanupTimer.unref?.();
|
|
333
|
+
}
|
|
334
|
+
stopCacheCleanup() {
|
|
335
|
+
if (this.cacheCleanupTimer !== undefined) {
|
|
336
|
+
clearInterval(this.cacheCleanupTimer);
|
|
337
|
+
this.cacheCleanupTimer = undefined;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
cleanupExpiredCache() {
|
|
341
|
+
const now = Date.now();
|
|
342
|
+
// Collect keys of expired entries first to avoid mutating the Map
|
|
343
|
+
// while iterating over its live iterator.
|
|
344
|
+
const keysToDelete = [];
|
|
345
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
346
|
+
if (entry.expiry <= now) {
|
|
347
|
+
keysToDelete.push(key);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const key of keysToDelete) {
|
|
351
|
+
this.cache.delete(key);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Releases all resources held by this client instance.
|
|
356
|
+
* Stops the cache cleanup timer, clears the cache, and removes this instance
|
|
357
|
+
* from the active-clients registry. Safe to call multiple times (idempotent).
|
|
358
|
+
* Also called automatically on `exit`, `SIGINT`, and `SIGTERM`.
|
|
359
|
+
*/
|
|
360
|
+
destroy() {
|
|
361
|
+
if (this.isDestroyed) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
this.isDestroyed = true;
|
|
365
|
+
this.stopCacheCleanup();
|
|
366
|
+
this.clearCache();
|
|
367
|
+
// Zero the in-memory credential to reduce exposure window.
|
|
368
|
+
this.auth = '';
|
|
369
|
+
// Remove this instance from the active clients set
|
|
370
|
+
activeClients.delete(this);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Makes an HTTP request to the TestRail API with caching, rate limiting, and retry logic.
|
|
374
|
+
*
|
|
375
|
+
* @param method - HTTP method (GET, POST)
|
|
376
|
+
* @param endpoint - API endpoint path (without base URL prefix)
|
|
377
|
+
* @param data - Optional request body
|
|
378
|
+
* @param retryCount - Current retry attempt (internal — do not pass)
|
|
379
|
+
* @param skipCache - Skip cache lookup and storage for this request
|
|
380
|
+
* @throws {TestRailApiError} When the API request fails or network error occurs
|
|
381
|
+
* @throws {Error} When called after `destroy()`
|
|
382
|
+
*/
|
|
383
|
+
async request(method, endpoint, data, retryCount = 0, skipCache = false) {
|
|
384
|
+
// Prevent use after destroy
|
|
385
|
+
if (this.isDestroyed) {
|
|
386
|
+
throw new Error('Cannot use TestRailClient after destroy() has been called');
|
|
387
|
+
}
|
|
388
|
+
// Check cache for GET requests
|
|
389
|
+
if (method === 'GET' && !skipCache) {
|
|
390
|
+
const cacheKey = `${method}:${endpoint}`;
|
|
391
|
+
const cachedData = this.getCachedData(cacheKey);
|
|
392
|
+
if (cachedData !== undefined) {
|
|
393
|
+
return cachedData;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Apply rate limiting
|
|
397
|
+
this.checkRateLimit();
|
|
398
|
+
const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
|
|
399
|
+
const headers = {
|
|
400
|
+
Authorization: `Basic ${this.auth}`,
|
|
401
|
+
'Content-Type': 'application/json',
|
|
402
|
+
'User-Agent': USER_AGENT,
|
|
403
|
+
};
|
|
404
|
+
const controller = new AbortController();
|
|
405
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
406
|
+
const options = {
|
|
407
|
+
method,
|
|
408
|
+
headers,
|
|
409
|
+
signal: controller.signal,
|
|
410
|
+
};
|
|
411
|
+
if (data !== undefined) {
|
|
412
|
+
options.body = JSON.stringify(data);
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const response = await fetch(url, options);
|
|
416
|
+
clearTimeout(timeoutId);
|
|
417
|
+
if (!response.ok) {
|
|
418
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
419
|
+
// Retry strategy for 5xx (Server Errors) and 429 (Too Many Requests).
|
|
420
|
+
// For 429, respect the Retry-After header if present; otherwise use exponential backoff.
|
|
421
|
+
if ((response.status >= 500 || response.status === 429) && retryCount < this.maxRetries) {
|
|
422
|
+
const retryAfterMs = response.status === 429 ? this.parseRetryAfterMs(response) : null;
|
|
423
|
+
const delay = retryAfterMs ?? this.getRetryDelay(retryCount);
|
|
424
|
+
await sleep(delay);
|
|
425
|
+
return this.request(method, endpoint, data, retryCount + 1, skipCache);
|
|
426
|
+
}
|
|
427
|
+
// The raw server body may contain stack traces, internal paths,
|
|
428
|
+
// or secret values. Keep it in the structured `response` field for
|
|
429
|
+
// programmatic inspection but do not embed it in the message string,
|
|
430
|
+
// which callers commonly pass to loggers.
|
|
431
|
+
throw new TestRailApiError(`TestRail API error: ${response.status} ${response.statusText}`, response.status, response.statusText, errorText);
|
|
432
|
+
}
|
|
433
|
+
// Invalidate cache after mutating requests to avoid stale GET results.
|
|
434
|
+
// Done before the empty-body check so empty responses (e.g. delete endpoints)
|
|
435
|
+
// also clear the cache.
|
|
436
|
+
if (method !== 'GET') {
|
|
437
|
+
this.clearCache();
|
|
438
|
+
}
|
|
439
|
+
const responseText = await response.text();
|
|
440
|
+
if (!responseText) {
|
|
441
|
+
return {};
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const result = JSON.parse(responseText);
|
|
445
|
+
// Cache successful GET responses
|
|
446
|
+
if (method === 'GET' && !skipCache) {
|
|
447
|
+
const cacheKey = `${method}:${endpoint}`;
|
|
448
|
+
this.setCachedData(cacheKey, result);
|
|
449
|
+
}
|
|
450
|
+
return result;
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
throw new TestRailApiError('Invalid JSON response from TestRail API');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
clearTimeout(timeoutId);
|
|
458
|
+
if (error instanceof TestRailApiError) {
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
const isAbortError = error.name === 'AbortError';
|
|
462
|
+
// Don't retry timeout errors to avoid excessive wait times
|
|
463
|
+
if (isAbortError) {
|
|
464
|
+
throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
|
|
465
|
+
}
|
|
466
|
+
// Retry on network errors up to the maximum number of retries
|
|
467
|
+
if (retryCount < this.maxRetries) {
|
|
468
|
+
await sleep(this.getRetryDelay(retryCount));
|
|
469
|
+
return this.request(method, endpoint, data, retryCount + 1, skipCache);
|
|
470
|
+
}
|
|
471
|
+
throw new TestRailApiError(`Network error: ${error.message}`, undefined, undefined, error.message);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Makes a multipart/form-data POST request to the TestRail API.
|
|
476
|
+
* Used exclusively for file attachment uploads. Applies rate limiting
|
|
477
|
+
* and throws on failure, but does NOT retry (uploads are not idempotent).
|
|
478
|
+
*
|
|
479
|
+
* @param endpoint - API endpoint path (without base URL prefix)
|
|
480
|
+
* @param file - File content as Blob, Uint8Array, or File
|
|
481
|
+
* @param filename - Filename to send in the multipart disposition
|
|
482
|
+
* @throws {TestRailApiError} When the API request fails or network error occurs
|
|
483
|
+
* @throws {Error} When called after `destroy()`
|
|
484
|
+
*/
|
|
485
|
+
async requestMultipart(endpoint, file, filename) {
|
|
486
|
+
if (this.isDestroyed) {
|
|
487
|
+
throw new Error('Cannot use TestRailClient after destroy() has been called');
|
|
488
|
+
}
|
|
489
|
+
this.checkRateLimit();
|
|
490
|
+
const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
|
|
491
|
+
const formData = new globalThis.FormData();
|
|
492
|
+
let blob;
|
|
493
|
+
if (file instanceof globalThis.Blob) {
|
|
494
|
+
blob = file;
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
// Copy binary-like input into a plain Uint8Array to satisfy BlobPart type constraints
|
|
498
|
+
blob = new globalThis.Blob([new Uint8Array(file)]);
|
|
499
|
+
}
|
|
500
|
+
formData.append('attachment', blob, filename);
|
|
501
|
+
const controller = new AbortController();
|
|
502
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
503
|
+
try {
|
|
504
|
+
const response = await fetch(url, {
|
|
505
|
+
method: 'POST',
|
|
506
|
+
headers: {
|
|
507
|
+
Authorization: `Basic ${this.auth}`,
|
|
508
|
+
'User-Agent': USER_AGENT,
|
|
509
|
+
// Do NOT set Content-Type — fetch sets it automatically with the boundary
|
|
510
|
+
},
|
|
511
|
+
body: formData,
|
|
512
|
+
signal: controller.signal,
|
|
513
|
+
});
|
|
514
|
+
clearTimeout(timeoutId);
|
|
515
|
+
if (!response.ok) {
|
|
516
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
517
|
+
throw new TestRailApiError(`TestRail API error: ${response.status} ${response.statusText}`, response.status, response.statusText, errorText);
|
|
518
|
+
}
|
|
519
|
+
// Invalidate cache after upload
|
|
520
|
+
this.clearCache();
|
|
521
|
+
const responseText = await response.text();
|
|
522
|
+
if (!responseText) {
|
|
523
|
+
return {};
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
return JSON.parse(responseText);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
throw new TestRailApiError('Invalid JSON response from TestRail API');
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
clearTimeout(timeoutId);
|
|
534
|
+
if (error instanceof TestRailApiError) {
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
const isAbortError = error.name === 'AbortError';
|
|
538
|
+
if (isAbortError) {
|
|
539
|
+
throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
|
|
540
|
+
}
|
|
541
|
+
throw new TestRailApiError(`Network error: ${error.message}`, undefined, undefined, error.message);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Makes a GET request to the TestRail API and returns the raw binary response.
|
|
546
|
+
* Used for downloading attachment contents.
|
|
547
|
+
*
|
|
548
|
+
* @param endpoint - API endpoint path (without base URL prefix)
|
|
549
|
+
* @throws {TestRailApiError} When the API request fails or network error occurs
|
|
550
|
+
* @throws {Error} When called after `destroy()`
|
|
551
|
+
*/
|
|
552
|
+
async requestBinary(endpoint, retryCount = 0) {
|
|
553
|
+
if (this.isDestroyed) {
|
|
554
|
+
throw new Error('Cannot use TestRailClient after destroy() has been called');
|
|
555
|
+
}
|
|
556
|
+
this.checkRateLimit();
|
|
557
|
+
const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
|
|
558
|
+
const controller = new AbortController();
|
|
559
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
560
|
+
try {
|
|
561
|
+
const response = await fetch(url, {
|
|
562
|
+
method: 'GET',
|
|
563
|
+
headers: {
|
|
564
|
+
Authorization: `Basic ${this.auth}`,
|
|
565
|
+
'User-Agent': USER_AGENT,
|
|
566
|
+
},
|
|
567
|
+
signal: controller.signal,
|
|
568
|
+
});
|
|
569
|
+
clearTimeout(timeoutId);
|
|
570
|
+
if (!response.ok) {
|
|
571
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
572
|
+
// Retry strategy for 5xx (Server Errors) and 429 (Too Many Requests).
|
|
573
|
+
// For 429, respect Retry-After header if present; otherwise use exponential backoff.
|
|
574
|
+
if ((response.status >= 500 || response.status === 429) && retryCount < this.maxRetries) {
|
|
575
|
+
const retryAfterMs = response.status === 429 ? this.parseRetryAfterMs(response) : null;
|
|
576
|
+
const delay = retryAfterMs ?? this.getRetryDelay(retryCount);
|
|
577
|
+
await sleep(delay);
|
|
578
|
+
return this.requestBinary(endpoint, retryCount + 1);
|
|
579
|
+
}
|
|
580
|
+
throw new TestRailApiError(`TestRail API error: ${response.status} ${response.statusText}`, response.status, response.statusText, errorText);
|
|
581
|
+
}
|
|
582
|
+
return response.arrayBuffer();
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
clearTimeout(timeoutId);
|
|
586
|
+
if (error instanceof TestRailApiError) {
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
const isAbortError = error.name === 'AbortError';
|
|
590
|
+
if (isAbortError) {
|
|
591
|
+
throw new TestRailApiError(`Request timeout after ${this.timeout}ms`);
|
|
592
|
+
}
|
|
593
|
+
if (retryCount < this.maxRetries) {
|
|
594
|
+
await sleep(this.getRetryDelay(retryCount));
|
|
595
|
+
return this.requestBinary(endpoint, retryCount + 1);
|
|
596
|
+
}
|
|
597
|
+
throw new TestRailApiError(`Network error: ${error.message}`, undefined, undefined, error.message);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
//# sourceMappingURL=client-core.js.map
|