@jgardner04/ghost-mcp-server 1.1.6 → 1.1.8
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/package.json
CHANGED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
BaseError,
|
|
4
|
+
ValidationError,
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
AuthorizationError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
ExternalServiceError,
|
|
11
|
+
GhostAPIError,
|
|
12
|
+
MCPProtocolError,
|
|
13
|
+
ToolExecutionError,
|
|
14
|
+
ImageProcessingError,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
ErrorHandler,
|
|
17
|
+
CircuitBreaker,
|
|
18
|
+
retryWithBackoff,
|
|
19
|
+
} from '../index.js';
|
|
20
|
+
|
|
21
|
+
describe('Error Handling System', () => {
|
|
22
|
+
describe('BaseError', () => {
|
|
23
|
+
it('should create error with default values', () => {
|
|
24
|
+
const error = new BaseError('Test error');
|
|
25
|
+
|
|
26
|
+
expect(error.message).toBe('Test error');
|
|
27
|
+
expect(error.statusCode).toBe(500);
|
|
28
|
+
expect(error.code).toBe('INTERNAL_ERROR');
|
|
29
|
+
expect(error.isOperational).toBe(true);
|
|
30
|
+
expect(error.name).toBe('BaseError');
|
|
31
|
+
expect(error.timestamp).toBeDefined();
|
|
32
|
+
expect(error.stack).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should create error with custom values', () => {
|
|
36
|
+
const error = new BaseError('Custom error', 400, 'CUSTOM_CODE', false);
|
|
37
|
+
|
|
38
|
+
expect(error.message).toBe('Custom error');
|
|
39
|
+
expect(error.statusCode).toBe(400);
|
|
40
|
+
expect(error.code).toBe('CUSTOM_CODE');
|
|
41
|
+
expect(error.isOperational).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should serialize to JSON in development mode', () => {
|
|
45
|
+
const originalEnv = process.env.NODE_ENV;
|
|
46
|
+
process.env.NODE_ENV = 'development';
|
|
47
|
+
|
|
48
|
+
const error = new BaseError('Test error', 500, 'TEST_CODE');
|
|
49
|
+
const json = error.toJSON();
|
|
50
|
+
|
|
51
|
+
expect(json.name).toBe('BaseError');
|
|
52
|
+
expect(json.message).toBe('Test error');
|
|
53
|
+
expect(json.code).toBe('TEST_CODE');
|
|
54
|
+
expect(json.statusCode).toBe(500);
|
|
55
|
+
expect(json.timestamp).toBeDefined();
|
|
56
|
+
expect(json.stack).toBeDefined();
|
|
57
|
+
|
|
58
|
+
process.env.NODE_ENV = originalEnv;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should not include stack in JSON in production mode', () => {
|
|
62
|
+
const originalEnv = process.env.NODE_ENV;
|
|
63
|
+
process.env.NODE_ENV = 'production';
|
|
64
|
+
|
|
65
|
+
const error = new BaseError('Test error');
|
|
66
|
+
const json = error.toJSON();
|
|
67
|
+
|
|
68
|
+
expect(json.stack).toBeUndefined();
|
|
69
|
+
|
|
70
|
+
process.env.NODE_ENV = originalEnv;
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('ValidationError', () => {
|
|
75
|
+
it('should create validation error with default values', () => {
|
|
76
|
+
const error = new ValidationError('Validation failed');
|
|
77
|
+
|
|
78
|
+
expect(error.message).toBe('Validation failed');
|
|
79
|
+
expect(error.statusCode).toBe(400);
|
|
80
|
+
expect(error.code).toBe('VALIDATION_ERROR');
|
|
81
|
+
expect(error.errors).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should create validation error with error details', () => {
|
|
85
|
+
const errors = [
|
|
86
|
+
{ field: 'email', message: 'Invalid email', type: 'string.email' },
|
|
87
|
+
{ field: 'age', message: 'Must be positive', type: 'number.positive' },
|
|
88
|
+
];
|
|
89
|
+
const error = new ValidationError('Validation failed', errors);
|
|
90
|
+
|
|
91
|
+
expect(error.errors).toEqual(errors);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should create validation error from Joi error', () => {
|
|
95
|
+
const joiError = {
|
|
96
|
+
details: [
|
|
97
|
+
{
|
|
98
|
+
path: ['user', 'email'],
|
|
99
|
+
message: '"user.email" must be a valid email',
|
|
100
|
+
type: 'string.email',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
path: ['age'],
|
|
104
|
+
message: '"age" must be a number',
|
|
105
|
+
type: 'number.base',
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const error = ValidationError.fromJoi(joiError);
|
|
111
|
+
|
|
112
|
+
expect(error.message).toBe('Validation failed');
|
|
113
|
+
expect(error.errors).toHaveLength(2);
|
|
114
|
+
expect(error.errors[0]).toEqual({
|
|
115
|
+
field: 'user.email',
|
|
116
|
+
message: '"user.email" must be a valid email',
|
|
117
|
+
type: 'string.email',
|
|
118
|
+
});
|
|
119
|
+
expect(error.errors[1]).toEqual({
|
|
120
|
+
field: 'age',
|
|
121
|
+
message: '"age" must be a number',
|
|
122
|
+
type: 'number.base',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('AuthenticationError', () => {
|
|
128
|
+
it('should create authentication error with default message', () => {
|
|
129
|
+
const error = new AuthenticationError();
|
|
130
|
+
|
|
131
|
+
expect(error.message).toBe('Authentication failed');
|
|
132
|
+
expect(error.statusCode).toBe(401);
|
|
133
|
+
expect(error.code).toBe('AUTHENTICATION_ERROR');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should create authentication error with custom message', () => {
|
|
137
|
+
const error = new AuthenticationError('Invalid API key');
|
|
138
|
+
|
|
139
|
+
expect(error.message).toBe('Invalid API key');
|
|
140
|
+
expect(error.statusCode).toBe(401);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('AuthorizationError', () => {
|
|
145
|
+
it('should create authorization error with default message', () => {
|
|
146
|
+
const error = new AuthorizationError();
|
|
147
|
+
|
|
148
|
+
expect(error.message).toBe('Access denied');
|
|
149
|
+
expect(error.statusCode).toBe(403);
|
|
150
|
+
expect(error.code).toBe('AUTHORIZATION_ERROR');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should create authorization error with custom message', () => {
|
|
154
|
+
const error = new AuthorizationError('Insufficient permissions');
|
|
155
|
+
|
|
156
|
+
expect(error.message).toBe('Insufficient permissions');
|
|
157
|
+
expect(error.statusCode).toBe(403);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('NotFoundError', () => {
|
|
162
|
+
it('should create not found error with resource and identifier', () => {
|
|
163
|
+
const error = new NotFoundError('Post', '123');
|
|
164
|
+
|
|
165
|
+
expect(error.message).toBe('Post not found: 123');
|
|
166
|
+
expect(error.statusCode).toBe(404);
|
|
167
|
+
expect(error.code).toBe('NOT_FOUND');
|
|
168
|
+
expect(error.resource).toBe('Post');
|
|
169
|
+
expect(error.identifier).toBe('123');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('ConflictError', () => {
|
|
174
|
+
it('should create conflict error with resource', () => {
|
|
175
|
+
const error = new ConflictError('Tag already exists', 'Tag');
|
|
176
|
+
|
|
177
|
+
expect(error.message).toBe('Tag already exists');
|
|
178
|
+
expect(error.statusCode).toBe(409);
|
|
179
|
+
expect(error.code).toBe('CONFLICT');
|
|
180
|
+
expect(error.resource).toBe('Tag');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('RateLimitError', () => {
|
|
185
|
+
it('should create rate limit error with default retryAfter', () => {
|
|
186
|
+
const error = new RateLimitError();
|
|
187
|
+
|
|
188
|
+
expect(error.message).toBe('Rate limit exceeded');
|
|
189
|
+
expect(error.statusCode).toBe(429);
|
|
190
|
+
expect(error.code).toBe('RATE_LIMIT_EXCEEDED');
|
|
191
|
+
expect(error.retryAfter).toBe(60);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should create rate limit error with custom retryAfter', () => {
|
|
195
|
+
const error = new RateLimitError(120);
|
|
196
|
+
|
|
197
|
+
expect(error.retryAfter).toBe(120);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('ExternalServiceError', () => {
|
|
202
|
+
it('should create external service error with service name', () => {
|
|
203
|
+
const error = new ExternalServiceError('Ghost API', 'Connection failed');
|
|
204
|
+
|
|
205
|
+
expect(error.message).toBe('External service error: Ghost API');
|
|
206
|
+
expect(error.statusCode).toBe(502);
|
|
207
|
+
expect(error.code).toBe('EXTERNAL_SERVICE_ERROR');
|
|
208
|
+
expect(error.service).toBe('Ghost API');
|
|
209
|
+
expect(error.originalError).toBe('Connection failed');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should handle Error object as originalError', () => {
|
|
213
|
+
const originalError = new Error('Network timeout');
|
|
214
|
+
const error = new ExternalServiceError('API', originalError);
|
|
215
|
+
|
|
216
|
+
expect(error.originalError).toBe('Network timeout');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('GhostAPIError', () => {
|
|
221
|
+
it('should map 401 status code correctly', () => {
|
|
222
|
+
const error = new GhostAPIError('createPost', 'Unauthorized', 401);
|
|
223
|
+
|
|
224
|
+
expect(error.statusCode).toBe(401);
|
|
225
|
+
expect(error.code).toBe('GHOST_AUTH_ERROR');
|
|
226
|
+
expect(error.operation).toBe('createPost');
|
|
227
|
+
expect(error.ghostStatusCode).toBe(401);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should map 404 status code correctly', () => {
|
|
231
|
+
const error = new GhostAPIError('getPost', 'Not found', 404);
|
|
232
|
+
|
|
233
|
+
expect(error.statusCode).toBe(404);
|
|
234
|
+
expect(error.code).toBe('GHOST_NOT_FOUND');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should map 422 status code to 400', () => {
|
|
238
|
+
const error = new GhostAPIError('updatePost', 'Invalid data', 422);
|
|
239
|
+
|
|
240
|
+
expect(error.statusCode).toBe(400);
|
|
241
|
+
expect(error.code).toBe('GHOST_VALIDATION_ERROR');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should map 429 status code correctly', () => {
|
|
245
|
+
const error = new GhostAPIError('getPosts', 'Rate limited', 429);
|
|
246
|
+
|
|
247
|
+
expect(error.statusCode).toBe(429);
|
|
248
|
+
expect(error.code).toBe('GHOST_RATE_LIMIT');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should keep default 502 for other status codes', () => {
|
|
252
|
+
const error = new GhostAPIError('operation', 'Server error', 500);
|
|
253
|
+
|
|
254
|
+
expect(error.statusCode).toBe(502);
|
|
255
|
+
expect(error.code).toBe('EXTERNAL_SERVICE_ERROR');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('MCPProtocolError', () => {
|
|
260
|
+
it('should create MCP protocol error with default details', () => {
|
|
261
|
+
const error = new MCPProtocolError('Invalid tool call');
|
|
262
|
+
|
|
263
|
+
expect(error.message).toBe('Invalid tool call');
|
|
264
|
+
expect(error.statusCode).toBe(400);
|
|
265
|
+
expect(error.code).toBe('MCP_PROTOCOL_ERROR');
|
|
266
|
+
expect(error.details).toEqual({});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should create MCP protocol error with details', () => {
|
|
270
|
+
const details = { tool: 'ghost_create_post', reason: 'Missing required field' };
|
|
271
|
+
const error = new MCPProtocolError('Invalid parameters', details);
|
|
272
|
+
|
|
273
|
+
expect(error.details).toEqual(details);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('ToolExecutionError', () => {
|
|
278
|
+
it('should create tool execution error with input', () => {
|
|
279
|
+
const input = { title: 'Test', tags: ['test'] };
|
|
280
|
+
const error = new ToolExecutionError('ghost_create_post', 'API error', input);
|
|
281
|
+
|
|
282
|
+
expect(error.message).toBe('Tool execution failed: ghost_create_post');
|
|
283
|
+
expect(error.statusCode).toBe(500);
|
|
284
|
+
expect(error.code).toBe('TOOL_EXECUTION_ERROR');
|
|
285
|
+
expect(error.toolName).toBe('ghost_create_post');
|
|
286
|
+
expect(error.originalError).toBe('API error');
|
|
287
|
+
expect(error.input).toEqual(input);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should handle Error object as originalError', () => {
|
|
291
|
+
const originalError = new Error('Execution failed');
|
|
292
|
+
const error = new ToolExecutionError('tool', originalError, {});
|
|
293
|
+
|
|
294
|
+
expect(error.originalError).toBe('Execution failed');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should filter sensitive data in production', () => {
|
|
298
|
+
const originalEnv = process.env.NODE_ENV;
|
|
299
|
+
process.env.NODE_ENV = 'production';
|
|
300
|
+
|
|
301
|
+
const input = {
|
|
302
|
+
apiKey: 'secret123',
|
|
303
|
+
password: 'pass123',
|
|
304
|
+
token: 'token123',
|
|
305
|
+
title: 'Post Title',
|
|
306
|
+
};
|
|
307
|
+
const error = new ToolExecutionError('tool', 'Error', input);
|
|
308
|
+
|
|
309
|
+
expect(error.input.apiKey).toBeUndefined();
|
|
310
|
+
expect(error.input.password).toBeUndefined();
|
|
311
|
+
expect(error.input.token).toBeUndefined();
|
|
312
|
+
expect(error.input.title).toBe('Post Title');
|
|
313
|
+
|
|
314
|
+
process.env.NODE_ENV = originalEnv;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should not filter sensitive data in development', () => {
|
|
318
|
+
const originalEnv = process.env.NODE_ENV;
|
|
319
|
+
process.env.NODE_ENV = 'development';
|
|
320
|
+
|
|
321
|
+
const input = {
|
|
322
|
+
apiKey: 'secret123',
|
|
323
|
+
password: 'pass123',
|
|
324
|
+
token: 'token123',
|
|
325
|
+
};
|
|
326
|
+
const error = new ToolExecutionError('tool', 'Error', input);
|
|
327
|
+
|
|
328
|
+
expect(error.input.apiKey).toBe('secret123');
|
|
329
|
+
expect(error.input.password).toBe('pass123');
|
|
330
|
+
expect(error.input.token).toBe('token123');
|
|
331
|
+
|
|
332
|
+
process.env.NODE_ENV = originalEnv;
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('ImageProcessingError', () => {
|
|
337
|
+
it('should create image processing error with operation', () => {
|
|
338
|
+
const error = new ImageProcessingError('resize', 'Invalid dimensions');
|
|
339
|
+
|
|
340
|
+
expect(error.message).toBe('Image processing failed: resize');
|
|
341
|
+
expect(error.statusCode).toBe(422);
|
|
342
|
+
expect(error.code).toBe('IMAGE_PROCESSING_ERROR');
|
|
343
|
+
expect(error.operation).toBe('resize');
|
|
344
|
+
expect(error.originalError).toBe('Invalid dimensions');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should handle Error object as originalError', () => {
|
|
348
|
+
const originalError = new Error('Sharp error');
|
|
349
|
+
const error = new ImageProcessingError('optimize', originalError);
|
|
350
|
+
|
|
351
|
+
expect(error.originalError).toBe('Sharp error');
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('ConfigurationError', () => {
|
|
356
|
+
it('should create configuration error with default missing fields', () => {
|
|
357
|
+
const error = new ConfigurationError('Missing configuration');
|
|
358
|
+
|
|
359
|
+
expect(error.message).toBe('Missing configuration');
|
|
360
|
+
expect(error.statusCode).toBe(500);
|
|
361
|
+
expect(error.code).toBe('CONFIGURATION_ERROR');
|
|
362
|
+
expect(error.isOperational).toBe(false);
|
|
363
|
+
expect(error.missingFields).toEqual([]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should create configuration error with missing fields', () => {
|
|
367
|
+
const error = new ConfigurationError('Invalid config', ['GHOST_API_URL', 'GHOST_API_KEY']);
|
|
368
|
+
|
|
369
|
+
expect(error.missingFields).toEqual(['GHOST_API_URL', 'GHOST_API_KEY']);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('ErrorHandler', () => {
|
|
374
|
+
describe('isOperationalError', () => {
|
|
375
|
+
it('should return true for BaseError with isOperational=true', () => {
|
|
376
|
+
const error = new BaseError('Test', 500, 'TEST', true);
|
|
377
|
+
expect(ErrorHandler.isOperationalError(error)).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should return false for BaseError with isOperational=false', () => {
|
|
381
|
+
const error = new ConfigurationError('Test');
|
|
382
|
+
expect(ErrorHandler.isOperationalError(error)).toBe(false);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should return false for regular Error', () => {
|
|
386
|
+
const error = new Error('Regular error');
|
|
387
|
+
expect(ErrorHandler.isOperationalError(error)).toBe(false);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe('formatMCPError', () => {
|
|
392
|
+
it('should format BaseError for MCP response', () => {
|
|
393
|
+
const error = new ValidationError('Invalid input', [
|
|
394
|
+
{ field: 'email', message: 'Invalid' },
|
|
395
|
+
]);
|
|
396
|
+
const formatted = ErrorHandler.formatMCPError(error);
|
|
397
|
+
|
|
398
|
+
expect(formatted.error.code).toBe('VALIDATION_ERROR');
|
|
399
|
+
expect(formatted.error.message).toBe('Invalid input');
|
|
400
|
+
expect(formatted.error.statusCode).toBe(400);
|
|
401
|
+
expect(formatted.error.validationErrors).toHaveLength(1);
|
|
402
|
+
expect(formatted.error.timestamp).toBeDefined();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('should include tool name when provided', () => {
|
|
406
|
+
const error = new BaseError('Test error');
|
|
407
|
+
const formatted = ErrorHandler.formatMCPError(error, 'ghost_create_post');
|
|
408
|
+
|
|
409
|
+
expect(formatted.error.tool).toBe('ghost_create_post');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should include retryAfter for RateLimitError', () => {
|
|
413
|
+
const error = new RateLimitError(120);
|
|
414
|
+
const formatted = ErrorHandler.formatMCPError(error);
|
|
415
|
+
|
|
416
|
+
expect(formatted.error.retryAfter).toBe(120);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should format unknown error in production', () => {
|
|
420
|
+
const originalEnv = process.env.NODE_ENV;
|
|
421
|
+
process.env.NODE_ENV = 'production';
|
|
422
|
+
|
|
423
|
+
const error = new Error('Unexpected error');
|
|
424
|
+
const formatted = ErrorHandler.formatMCPError(error);
|
|
425
|
+
|
|
426
|
+
expect(formatted.error.code).toBe('UNKNOWN_ERROR');
|
|
427
|
+
expect(formatted.error.message).toBe('An unexpected error occurred');
|
|
428
|
+
expect(formatted.error.statusCode).toBe(500);
|
|
429
|
+
|
|
430
|
+
process.env.NODE_ENV = originalEnv;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should include error message for unknown error in development', () => {
|
|
434
|
+
const originalEnv = process.env.NODE_ENV;
|
|
435
|
+
process.env.NODE_ENV = 'development';
|
|
436
|
+
|
|
437
|
+
const error = new Error('Debug error');
|
|
438
|
+
const formatted = ErrorHandler.formatMCPError(error);
|
|
439
|
+
|
|
440
|
+
expect(formatted.error.message).toBe('Debug error');
|
|
441
|
+
|
|
442
|
+
process.env.NODE_ENV = originalEnv;
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('formatHTTPError', () => {
|
|
447
|
+
it('should format BaseError for HTTP response', () => {
|
|
448
|
+
const error = new NotFoundError('Post', '123');
|
|
449
|
+
const formatted = ErrorHandler.formatHTTPError(error);
|
|
450
|
+
|
|
451
|
+
expect(formatted.statusCode).toBe(404);
|
|
452
|
+
expect(formatted.body.error.code).toBe('NOT_FOUND');
|
|
453
|
+
expect(formatted.body.error.message).toBe('Post not found: 123');
|
|
454
|
+
expect(formatted.body.error.resource).toBe('Post');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('should include validation errors', () => {
|
|
458
|
+
const error = new ValidationError('Invalid', [{ field: 'name', message: 'Required' }]);
|
|
459
|
+
const formatted = ErrorHandler.formatHTTPError(error);
|
|
460
|
+
|
|
461
|
+
expect(formatted.body.error.errors).toHaveLength(1);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should include retryAfter for rate limit', () => {
|
|
465
|
+
const error = new RateLimitError(60);
|
|
466
|
+
const formatted = ErrorHandler.formatHTTPError(error);
|
|
467
|
+
|
|
468
|
+
expect(formatted.body.error.retryAfter).toBe(60);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('should format unknown error in production', () => {
|
|
472
|
+
const originalEnv = process.env.NODE_ENV;
|
|
473
|
+
process.env.NODE_ENV = 'production';
|
|
474
|
+
|
|
475
|
+
const error = new Error('Unknown');
|
|
476
|
+
const formatted = ErrorHandler.formatHTTPError(error);
|
|
477
|
+
|
|
478
|
+
expect(formatted.statusCode).toBe(500);
|
|
479
|
+
expect(formatted.body.error.code).toBe('INTERNAL_ERROR');
|
|
480
|
+
expect(formatted.body.error.message).toBe('An internal error occurred');
|
|
481
|
+
|
|
482
|
+
process.env.NODE_ENV = originalEnv;
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('should include error message for unknown error in development', () => {
|
|
486
|
+
const originalEnv = process.env.NODE_ENV;
|
|
487
|
+
process.env.NODE_ENV = 'development';
|
|
488
|
+
|
|
489
|
+
const error = new Error('Debug message');
|
|
490
|
+
const formatted = ErrorHandler.formatHTTPError(error);
|
|
491
|
+
|
|
492
|
+
expect(formatted.body.error.message).toBe('Debug message');
|
|
493
|
+
|
|
494
|
+
process.env.NODE_ENV = originalEnv;
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
describe('asyncWrapper', () => {
|
|
499
|
+
it('should pass through successful results', async () => {
|
|
500
|
+
const fn = async () => 'success';
|
|
501
|
+
const wrapped = ErrorHandler.asyncWrapper(fn);
|
|
502
|
+
|
|
503
|
+
const result = await wrapped();
|
|
504
|
+
expect(result).toBe('success');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('should rethrow operational errors', async () => {
|
|
508
|
+
const error = new ValidationError('Invalid');
|
|
509
|
+
const fn = async () => {
|
|
510
|
+
throw error;
|
|
511
|
+
};
|
|
512
|
+
const wrapped = ErrorHandler.asyncWrapper(fn);
|
|
513
|
+
|
|
514
|
+
await expect(wrapped()).rejects.toThrow(error);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should log and rethrow non-operational errors', async () => {
|
|
518
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
519
|
+
const error = new Error('Programming error');
|
|
520
|
+
const fn = async () => {
|
|
521
|
+
throw error;
|
|
522
|
+
};
|
|
523
|
+
const wrapped = ErrorHandler.asyncWrapper(fn);
|
|
524
|
+
|
|
525
|
+
await expect(wrapped()).rejects.toThrow(error);
|
|
526
|
+
expect(consoleSpy).toHaveBeenCalledWith('Unexpected error:', error);
|
|
527
|
+
|
|
528
|
+
consoleSpy.mockRestore();
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('fromGhostError', () => {
|
|
533
|
+
it('should create GhostAPIError from error with response', () => {
|
|
534
|
+
const ghostError = {
|
|
535
|
+
response: {
|
|
536
|
+
status: 404,
|
|
537
|
+
data: {
|
|
538
|
+
errors: [{ message: 'Post not found' }],
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const error = ErrorHandler.fromGhostError(ghostError, 'getPost');
|
|
544
|
+
|
|
545
|
+
expect(error).toBeInstanceOf(GhostAPIError);
|
|
546
|
+
expect(error.operation).toBe('getPost');
|
|
547
|
+
expect(error.ghostStatusCode).toBe(404);
|
|
548
|
+
expect(error.originalError).toBe('Post not found');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('should handle error without response', () => {
|
|
552
|
+
const ghostError = {
|
|
553
|
+
statusCode: 500,
|
|
554
|
+
message: 'Server error',
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const error = ErrorHandler.fromGhostError(ghostError, 'operation');
|
|
558
|
+
|
|
559
|
+
expect(error.ghostStatusCode).toBe(500);
|
|
560
|
+
expect(error.originalError).toBe('Server error');
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
describe('isRetryable', () => {
|
|
565
|
+
it('should return true for RateLimitError', () => {
|
|
566
|
+
const error = new RateLimitError();
|
|
567
|
+
expect(ErrorHandler.isRetryable(error)).toBe(true);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('should return true for ExternalServiceError', () => {
|
|
571
|
+
const error = new ExternalServiceError('API', 'error');
|
|
572
|
+
expect(ErrorHandler.isRetryable(error)).toBe(true);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('should return true for GhostAPIError with retryable status codes', () => {
|
|
576
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 429))).toBe(true);
|
|
577
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 502))).toBe(true);
|
|
578
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 503))).toBe(true);
|
|
579
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 504))).toBe(true);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('should return true for GhostAPIError (extends ExternalServiceError)', () => {
|
|
583
|
+
// GhostAPIError extends ExternalServiceError, so it's always retryable
|
|
584
|
+
// The ghostStatusCode-specific logic is never reached due to instanceof check order
|
|
585
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 400))).toBe(true);
|
|
586
|
+
expect(ErrorHandler.isRetryable(new GhostAPIError('op', 'err', 404))).toBe(true);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('should return true for network errors', () => {
|
|
590
|
+
const econnrefused = new Error();
|
|
591
|
+
econnrefused.code = 'ECONNREFUSED';
|
|
592
|
+
expect(ErrorHandler.isRetryable(econnrefused)).toBe(true);
|
|
593
|
+
|
|
594
|
+
const etimedout = new Error();
|
|
595
|
+
etimedout.code = 'ETIMEDOUT';
|
|
596
|
+
expect(ErrorHandler.isRetryable(etimedout)).toBe(true);
|
|
597
|
+
|
|
598
|
+
const econnreset = new Error();
|
|
599
|
+
econnreset.code = 'ECONNRESET';
|
|
600
|
+
expect(ErrorHandler.isRetryable(econnreset)).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('should return false for non-retryable errors', () => {
|
|
604
|
+
expect(ErrorHandler.isRetryable(new ValidationError('Invalid'))).toBe(false);
|
|
605
|
+
expect(ErrorHandler.isRetryable(new Error('Unknown'))).toBe(false);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
describe('getRetryDelay', () => {
|
|
610
|
+
it('should return retryAfter for RateLimitError in milliseconds', () => {
|
|
611
|
+
const error = new RateLimitError(60);
|
|
612
|
+
const delay = ErrorHandler.getRetryDelay(1, error);
|
|
613
|
+
|
|
614
|
+
expect(delay).toBe(60000); // 60 seconds * 1000
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('should calculate exponential backoff for attempt 1', () => {
|
|
618
|
+
const error = new ExternalServiceError('API', 'error');
|
|
619
|
+
const delay = ErrorHandler.getRetryDelay(1, error);
|
|
620
|
+
|
|
621
|
+
// Base delay is 1000ms, with jitter of up to 30%
|
|
622
|
+
expect(delay).toBeGreaterThanOrEqual(1000);
|
|
623
|
+
expect(delay).toBeLessThanOrEqual(1300);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('should calculate exponential backoff for attempt 2', () => {
|
|
627
|
+
const error = new ExternalServiceError('API', 'error');
|
|
628
|
+
const delay = ErrorHandler.getRetryDelay(2, error);
|
|
629
|
+
|
|
630
|
+
// 2000ms base + up to 30% jitter
|
|
631
|
+
expect(delay).toBeGreaterThanOrEqual(2000);
|
|
632
|
+
expect(delay).toBeLessThanOrEqual(2600);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('should calculate exponential backoff for attempt 3', () => {
|
|
636
|
+
const error = new ExternalServiceError('API', 'error');
|
|
637
|
+
const delay = ErrorHandler.getRetryDelay(3, error);
|
|
638
|
+
|
|
639
|
+
// 4000ms base + up to 30% jitter
|
|
640
|
+
expect(delay).toBeGreaterThanOrEqual(4000);
|
|
641
|
+
expect(delay).toBeLessThanOrEqual(5200);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('should cap delay at maximum', () => {
|
|
645
|
+
const error = new ExternalServiceError('API', 'error');
|
|
646
|
+
const delay = ErrorHandler.getRetryDelay(10, error);
|
|
647
|
+
|
|
648
|
+
// Max delay is 30000ms + 30% jitter
|
|
649
|
+
expect(delay).toBeLessThanOrEqual(39000);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('should return integer delay', () => {
|
|
653
|
+
const error = new ExternalServiceError('API', 'error');
|
|
654
|
+
const delay = ErrorHandler.getRetryDelay(2, error);
|
|
655
|
+
|
|
656
|
+
expect(Number.isInteger(delay)).toBe(true);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
describe('CircuitBreaker', () => {
|
|
662
|
+
let breaker;
|
|
663
|
+
|
|
664
|
+
beforeEach(() => {
|
|
665
|
+
breaker = new CircuitBreaker({
|
|
666
|
+
failureThreshold: 3,
|
|
667
|
+
resetTimeout: 1000,
|
|
668
|
+
monitoringPeriod: 500,
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('should initialize in CLOSED state', () => {
|
|
673
|
+
const state = breaker.getState();
|
|
674
|
+
|
|
675
|
+
expect(state.state).toBe('CLOSED');
|
|
676
|
+
expect(state.failureCount).toBe(0);
|
|
677
|
+
expect(state.lastFailureTime).toBeNull();
|
|
678
|
+
expect(state.nextAttempt).toBeNull();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('should execute successful function in CLOSED state', async () => {
|
|
682
|
+
const fn = vi.fn().mockResolvedValue('success');
|
|
683
|
+
const result = await breaker.execute(fn);
|
|
684
|
+
|
|
685
|
+
expect(result).toBe('success');
|
|
686
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
687
|
+
expect(breaker.getState().state).toBe('CLOSED');
|
|
688
|
+
expect(breaker.getState().failureCount).toBe(0);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('should increment failure count on error', async () => {
|
|
692
|
+
const fn = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
693
|
+
|
|
694
|
+
await expect(breaker.execute(fn)).rejects.toThrow('Failed');
|
|
695
|
+
expect(breaker.getState().failureCount).toBe(1);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('should transition to OPEN after threshold failures', async () => {
|
|
699
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
700
|
+
const fn = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
701
|
+
|
|
702
|
+
// Fail 3 times (threshold)
|
|
703
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
704
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
705
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
706
|
+
|
|
707
|
+
const state = breaker.getState();
|
|
708
|
+
expect(state.state).toBe('OPEN');
|
|
709
|
+
expect(state.failureCount).toBe(3);
|
|
710
|
+
expect(state.nextAttempt).toBeGreaterThan(Date.now());
|
|
711
|
+
|
|
712
|
+
consoleSpy.mockRestore();
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('should reject immediately when OPEN', async () => {
|
|
716
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
717
|
+
const fn = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
718
|
+
|
|
719
|
+
// Trip the breaker
|
|
720
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
721
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
722
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
723
|
+
|
|
724
|
+
// Now it should reject immediately
|
|
725
|
+
await expect(breaker.execute(fn)).rejects.toThrow('Circuit breaker is OPEN');
|
|
726
|
+
expect(fn).toHaveBeenCalledTimes(3); // Not called the 4th time
|
|
727
|
+
|
|
728
|
+
consoleSpy.mockRestore();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('should transition to HALF_OPEN after timeout', async () => {
|
|
732
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
733
|
+
const fn = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
734
|
+
|
|
735
|
+
// Trip the breaker
|
|
736
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
737
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
738
|
+
await expect(breaker.execute(fn)).rejects.toThrow();
|
|
739
|
+
|
|
740
|
+
expect(breaker.getState().state).toBe('OPEN');
|
|
741
|
+
|
|
742
|
+
// Wait for reset timeout
|
|
743
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
744
|
+
|
|
745
|
+
// Next call should transition to HALF_OPEN
|
|
746
|
+
const successFn = vi.fn().mockResolvedValue('success');
|
|
747
|
+
const result = await breaker.execute(successFn);
|
|
748
|
+
|
|
749
|
+
expect(result).toBe('success');
|
|
750
|
+
expect(breaker.getState().state).toBe('CLOSED');
|
|
751
|
+
|
|
752
|
+
consoleSpy.mockRestore();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it('should reset to CLOSED on success in HALF_OPEN state', async () => {
|
|
756
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
757
|
+
const failFn = vi.fn().mockRejectedValue(new Error('Failed'));
|
|
758
|
+
|
|
759
|
+
// Trip the breaker
|
|
760
|
+
await expect(breaker.execute(failFn)).rejects.toThrow();
|
|
761
|
+
await expect(breaker.execute(failFn)).rejects.toThrow();
|
|
762
|
+
await expect(breaker.execute(failFn)).rejects.toThrow();
|
|
763
|
+
|
|
764
|
+
// Wait for timeout
|
|
765
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
766
|
+
|
|
767
|
+
// Successful call should reset to CLOSED
|
|
768
|
+
const successFn = vi.fn().mockResolvedValue('success');
|
|
769
|
+
await breaker.execute(successFn);
|
|
770
|
+
|
|
771
|
+
const state = breaker.getState();
|
|
772
|
+
expect(state.state).toBe('CLOSED');
|
|
773
|
+
expect(state.failureCount).toBe(0);
|
|
774
|
+
expect(state.lastFailureTime).toBeNull();
|
|
775
|
+
|
|
776
|
+
consoleSpy.mockRestore();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('should use default options when not provided', () => {
|
|
780
|
+
const defaultBreaker = new CircuitBreaker();
|
|
781
|
+
|
|
782
|
+
expect(defaultBreaker.failureThreshold).toBe(5);
|
|
783
|
+
expect(defaultBreaker.resetTimeout).toBe(60000);
|
|
784
|
+
expect(defaultBreaker.monitoringPeriod).toBe(10000);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
describe('retryWithBackoff', () => {
|
|
789
|
+
it('should return result on first successful attempt', async () => {
|
|
790
|
+
const fn = vi.fn().mockResolvedValue('success');
|
|
791
|
+
const result = await retryWithBackoff(fn);
|
|
792
|
+
|
|
793
|
+
expect(result).toBe('success');
|
|
794
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('should retry on retryable error and succeed', async () => {
|
|
798
|
+
const fn = vi
|
|
799
|
+
.fn()
|
|
800
|
+
.mockRejectedValueOnce(new RateLimitError(1))
|
|
801
|
+
.mockResolvedValueOnce('success');
|
|
802
|
+
|
|
803
|
+
const result = await retryWithBackoff(fn, { maxAttempts: 3 });
|
|
804
|
+
|
|
805
|
+
expect(result).toBe('success');
|
|
806
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('should throw after max attempts with retryable error', async () => {
|
|
810
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
811
|
+
const error = new ExternalServiceError('API', 'Failed');
|
|
812
|
+
const fn = vi.fn().mockRejectedValue(error);
|
|
813
|
+
|
|
814
|
+
await expect(retryWithBackoff(fn, { maxAttempts: 3 })).rejects.toThrow(error);
|
|
815
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
816
|
+
|
|
817
|
+
consoleSpy.mockRestore();
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('should not retry non-retryable errors', async () => {
|
|
821
|
+
const error = new ValidationError('Invalid input');
|
|
822
|
+
const fn = vi.fn().mockRejectedValue(error);
|
|
823
|
+
|
|
824
|
+
await expect(retryWithBackoff(fn, { maxAttempts: 3 })).rejects.toThrow(error);
|
|
825
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('should call onRetry callback on retry', async () => {
|
|
829
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
830
|
+
const onRetry = vi.fn();
|
|
831
|
+
const fn = vi
|
|
832
|
+
.fn()
|
|
833
|
+
.mockRejectedValueOnce(new RateLimitError(1))
|
|
834
|
+
.mockResolvedValueOnce('success');
|
|
835
|
+
|
|
836
|
+
await retryWithBackoff(fn, { maxAttempts: 3, onRetry });
|
|
837
|
+
|
|
838
|
+
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
839
|
+
expect(onRetry).toHaveBeenCalledWith(1, expect.any(RateLimitError));
|
|
840
|
+
|
|
841
|
+
consoleSpy.mockRestore();
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('should use default maxAttempts of 3', async () => {
|
|
845
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
846
|
+
const error = new RateLimitError(1);
|
|
847
|
+
const fn = vi.fn().mockRejectedValue(error);
|
|
848
|
+
|
|
849
|
+
await expect(retryWithBackoff(fn)).rejects.toThrow(error);
|
|
850
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
851
|
+
|
|
852
|
+
consoleSpy.mockRestore();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('should wait between retries', async () => {
|
|
856
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
857
|
+
const startTime = Date.now();
|
|
858
|
+
const fn = vi
|
|
859
|
+
.fn()
|
|
860
|
+
.mockRejectedValueOnce(new ExternalServiceError('API', 'Error'))
|
|
861
|
+
.mockResolvedValueOnce('success');
|
|
862
|
+
|
|
863
|
+
await retryWithBackoff(fn, { maxAttempts: 2 });
|
|
864
|
+
|
|
865
|
+
const duration = Date.now() - startTime;
|
|
866
|
+
// Should wait at least 1000ms (first retry delay)
|
|
867
|
+
expect(duration).toBeGreaterThanOrEqual(1000);
|
|
868
|
+
|
|
869
|
+
consoleSpy.mockRestore();
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
});
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateImageUrl, createSecureAxiosConfig, ALLOWED_DOMAINS } from '../urlValidator.js';
|
|
3
|
+
|
|
4
|
+
describe('urlValidator', () => {
|
|
5
|
+
describe('ALLOWED_DOMAINS', () => {
|
|
6
|
+
it('should export an array of allowed domains', () => {
|
|
7
|
+
expect(ALLOWED_DOMAINS).toBeInstanceOf(Array);
|
|
8
|
+
expect(ALLOWED_DOMAINS.length).toBeGreaterThan(0);
|
|
9
|
+
expect(ALLOWED_DOMAINS).toContain('imgur.com');
|
|
10
|
+
expect(ALLOWED_DOMAINS).toContain('github.com');
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('isSafeHost (tested via validateImageUrl)', () => {
|
|
15
|
+
describe('allowed domains', () => {
|
|
16
|
+
it('should allow imgur.com', () => {
|
|
17
|
+
const result = validateImageUrl('https://imgur.com/image.jpg');
|
|
18
|
+
expect(result.isValid).toBe(true);
|
|
19
|
+
expect(result.sanitizedUrl).toBe('https://imgur.com/image.jpg');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should allow i.imgur.com subdomain', () => {
|
|
23
|
+
const result = validateImageUrl('https://i.imgur.com/abc123.jpg');
|
|
24
|
+
expect(result.isValid).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should allow github.com', () => {
|
|
28
|
+
const result = validateImageUrl('https://github.com/user/repo/image.png');
|
|
29
|
+
expect(result.isValid).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should allow githubusercontent.com', () => {
|
|
33
|
+
const result = validateImageUrl('https://githubusercontent.com/image.jpg');
|
|
34
|
+
expect(result.isValid).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should allow unsplash.com', () => {
|
|
38
|
+
const result = validateImageUrl('https://unsplash.com/photo.jpg');
|
|
39
|
+
expect(result.isValid).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should allow images.unsplash.com subdomain', () => {
|
|
43
|
+
const result = validateImageUrl('https://images.unsplash.com/photo-123');
|
|
44
|
+
expect(result.isValid).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should allow cloudinary.com', () => {
|
|
48
|
+
const result = validateImageUrl('https://cloudinary.com/image.jpg');
|
|
49
|
+
expect(result.isValid).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should allow res.cloudinary.com subdomain', () => {
|
|
53
|
+
const result = validateImageUrl('https://res.cloudinary.com/demo/image.jpg');
|
|
54
|
+
expect(result.isValid).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should allow amazonaws.com', () => {
|
|
58
|
+
const result = validateImageUrl('https://amazonaws.com/bucket/image.jpg');
|
|
59
|
+
expect(result.isValid).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should allow s3.amazonaws.com subdomain', () => {
|
|
63
|
+
const result = validateImageUrl('https://s3.amazonaws.com/bucket/image.jpg');
|
|
64
|
+
expect(result.isValid).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should allow deep subdomains of allowed domains', () => {
|
|
68
|
+
const result = validateImageUrl('https://cdn.images.unsplash.com/photo.jpg');
|
|
69
|
+
expect(result.isValid).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('blocked IP patterns - IPv4 localhost and private ranges', () => {
|
|
74
|
+
it('should block 127.0.0.1 (localhost)', () => {
|
|
75
|
+
const result = validateImageUrl('https://127.0.0.1/image.jpg');
|
|
76
|
+
expect(result.isValid).toBe(false);
|
|
77
|
+
expect(result.error).toContain('not allowed for security reasons');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should block 127.0.0.2 (localhost range)', () => {
|
|
81
|
+
const result = validateImageUrl('https://127.0.0.2/image.jpg');
|
|
82
|
+
expect(result.isValid).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should block 127.255.255.255 (localhost range edge)', () => {
|
|
86
|
+
const result = validateImageUrl('https://127.255.255.255/image.jpg');
|
|
87
|
+
expect(result.isValid).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should block 10.0.0.1 (private network)', () => {
|
|
91
|
+
const result = validateImageUrl('https://10.0.0.1/image.jpg');
|
|
92
|
+
expect(result.isValid).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should block 10.255.255.255 (private network edge)', () => {
|
|
96
|
+
const result = validateImageUrl('https://10.255.255.255/image.jpg');
|
|
97
|
+
expect(result.isValid).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should block 192.168.0.1 (private network)', () => {
|
|
101
|
+
const result = validateImageUrl('https://192.168.0.1/image.jpg');
|
|
102
|
+
expect(result.isValid).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should block 192.168.255.255 (private network edge)', () => {
|
|
106
|
+
const result = validateImageUrl('https://192.168.255.255/image.jpg');
|
|
107
|
+
expect(result.isValid).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should block 172.16.0.1 (private network)', () => {
|
|
111
|
+
const result = validateImageUrl('https://172.16.0.1/image.jpg');
|
|
112
|
+
expect(result.isValid).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should block 172.31.255.255 (private network edge)', () => {
|
|
116
|
+
const result = validateImageUrl('https://172.31.255.255/image.jpg');
|
|
117
|
+
expect(result.isValid).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should block 169.254.0.1 (link local)', () => {
|
|
121
|
+
const result = validateImageUrl('https://169.254.0.1/image.jpg');
|
|
122
|
+
expect(result.isValid).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should block 0.0.0.0', () => {
|
|
126
|
+
const result = validateImageUrl('https://0.0.0.0/image.jpg');
|
|
127
|
+
expect(result.isValid).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should block 224.0.0.1 (multicast)', () => {
|
|
131
|
+
const result = validateImageUrl('https://224.0.0.1/image.jpg');
|
|
132
|
+
expect(result.isValid).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should block 240.0.0.1 (reserved)', () => {
|
|
136
|
+
const result = validateImageUrl('https://240.0.0.1/image.jpg');
|
|
137
|
+
expect(result.isValid).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should block 255.255.255.255 (broadcast)', () => {
|
|
141
|
+
const result = validateImageUrl('https://255.255.255.255/image.jpg');
|
|
142
|
+
expect(result.isValid).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('blocked IP patterns - IPv6', () => {
|
|
147
|
+
it('should block ::1 (IPv6 localhost)', () => {
|
|
148
|
+
const result = validateImageUrl('https://[::1]/image.jpg');
|
|
149
|
+
expect(result.isValid).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should block :: (IPv6 unspecified)', () => {
|
|
153
|
+
const result = validateImageUrl('https://[::]/image.jpg');
|
|
154
|
+
expect(result.isValid).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should block fc00:: (IPv6 unique local)', () => {
|
|
158
|
+
const result = validateImageUrl('https://[fc00::1]/image.jpg');
|
|
159
|
+
expect(result.isValid).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should block fe80:: (IPv6 link local)', () => {
|
|
163
|
+
const result = validateImageUrl('https://[fe80::1]/image.jpg');
|
|
164
|
+
expect(result.isValid).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should block ff00:: (IPv6 multicast)', () => {
|
|
168
|
+
const result = validateImageUrl('https://[ff00::1]/image.jpg');
|
|
169
|
+
expect(result.isValid).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('subdomain matching', () => {
|
|
174
|
+
it('should allow exact domain match', () => {
|
|
175
|
+
const result = validateImageUrl('https://imgur.com/image.jpg');
|
|
176
|
+
expect(result.isValid).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should allow subdomain with dot prefix', () => {
|
|
180
|
+
const result = validateImageUrl('https://i.imgur.com/image.jpg');
|
|
181
|
+
expect(result.isValid).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should allow multi-level subdomains', () => {
|
|
185
|
+
const result = validateImageUrl('https://cdn.images.imgur.com/image.jpg');
|
|
186
|
+
expect(result.isValid).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should reject domain that partially matches but is not a subdomain', () => {
|
|
190
|
+
const result = validateImageUrl('https://fakeimgur.com/image.jpg');
|
|
191
|
+
expect(result.isValid).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should reject domain with allowed domain as substring', () => {
|
|
195
|
+
const result = validateImageUrl('https://notimgur.com/image.jpg');
|
|
196
|
+
expect(result.isValid).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('hostname string checks for localhost/internal', () => {
|
|
201
|
+
it('should block localhost hostname', () => {
|
|
202
|
+
const result = validateImageUrl('https://localhost/image.jpg');
|
|
203
|
+
expect(result.isValid).toBe(false);
|
|
204
|
+
expect(result.error).toContain('not allowed for security reasons');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should block localhost with port', () => {
|
|
208
|
+
const result = validateImageUrl('https://localhost:3000/image.jpg');
|
|
209
|
+
expect(result.isValid).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should block 0.0.0.0 hostname', () => {
|
|
213
|
+
const result = validateImageUrl('https://0.0.0.0/image.jpg');
|
|
214
|
+
expect(result.isValid).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should block 192.168.x.x hostname pattern', () => {
|
|
218
|
+
const result = validateImageUrl('https://192.168.1.1/image.jpg');
|
|
219
|
+
expect(result.isValid).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should block 10.x.x.x hostname pattern', () => {
|
|
223
|
+
const result = validateImageUrl('https://10.1.1.1/image.jpg');
|
|
224
|
+
expect(result.isValid).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should block .local domains', () => {
|
|
228
|
+
const result = validateImageUrl('https://myserver.local/image.jpg');
|
|
229
|
+
expect(result.isValid).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('validateImageUrl', () => {
|
|
235
|
+
describe('valid HTTPS URLs', () => {
|
|
236
|
+
it('should accept valid HTTPS URL from allowed domain', () => {
|
|
237
|
+
const result = validateImageUrl('https://imgur.com/abc123.jpg');
|
|
238
|
+
expect(result.isValid).toBe(true);
|
|
239
|
+
expect(result.sanitizedUrl).toBe('https://imgur.com/abc123.jpg');
|
|
240
|
+
expect(result.error).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should accept HTTPS URL with query parameters', () => {
|
|
244
|
+
const result = validateImageUrl('https://imgur.com/image.jpg?size=large');
|
|
245
|
+
expect(result.isValid).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should accept HTTPS URL with fragment', () => {
|
|
249
|
+
const result = validateImageUrl('https://imgur.com/image.jpg#section');
|
|
250
|
+
expect(result.isValid).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should accept HTTPS URL with path segments', () => {
|
|
254
|
+
const result = validateImageUrl('https://github.com/user/repo/blob/main/image.png');
|
|
255
|
+
expect(result.isValid).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should accept HTTP URL from allowed domain', () => {
|
|
259
|
+
const result = validateImageUrl('http://imgur.com/image.jpg');
|
|
260
|
+
expect(result.isValid).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('invalid protocols', () => {
|
|
265
|
+
it('should reject file:// protocol', () => {
|
|
266
|
+
const result = validateImageUrl('file:///etc/passwd');
|
|
267
|
+
expect(result.isValid).toBe(false);
|
|
268
|
+
expect(result.error).toContain('Invalid URL format');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should reject ftp:// protocol', () => {
|
|
272
|
+
const result = validateImageUrl('ftp://example.com/image.jpg');
|
|
273
|
+
expect(result.isValid).toBe(false);
|
|
274
|
+
expect(result.error).toContain('Invalid URL format');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should reject data: protocol', () => {
|
|
278
|
+
const result = validateImageUrl('data:image/png;base64,iVBORw0KGgo');
|
|
279
|
+
expect(result.isValid).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should reject javascript: protocol', () => {
|
|
283
|
+
const result = validateImageUrl('javascript:alert("xss")');
|
|
284
|
+
expect(result.isValid).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should reject gopher:// protocol', () => {
|
|
288
|
+
const result = validateImageUrl('gopher://example.com/image');
|
|
289
|
+
expect(result.isValid).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('non-standard ports', () => {
|
|
294
|
+
it('should allow port 80', () => {
|
|
295
|
+
const result = validateImageUrl('http://imgur.com:80/image.jpg');
|
|
296
|
+
expect(result.isValid).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should allow port 443', () => {
|
|
300
|
+
const result = validateImageUrl('https://imgur.com:443/image.jpg');
|
|
301
|
+
expect(result.isValid).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should allow port 8080', () => {
|
|
305
|
+
const result = validateImageUrl('https://imgur.com:8080/image.jpg');
|
|
306
|
+
expect(result.isValid).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should allow port 8443', () => {
|
|
310
|
+
const result = validateImageUrl('https://imgur.com:8443/image.jpg');
|
|
311
|
+
expect(result.isValid).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should reject port 22 (SSH)', () => {
|
|
315
|
+
const result = validateImageUrl('https://imgur.com:22/image.jpg');
|
|
316
|
+
expect(result.isValid).toBe(false);
|
|
317
|
+
expect(result.error).toContain('non-standard port');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should reject port 3000', () => {
|
|
321
|
+
const result = validateImageUrl('https://imgur.com:3000/image.jpg');
|
|
322
|
+
expect(result.isValid).toBe(false);
|
|
323
|
+
expect(result.error).toContain('non-standard port');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should reject port 5432 (PostgreSQL)', () => {
|
|
327
|
+
const result = validateImageUrl('https://imgur.com:5432/image.jpg');
|
|
328
|
+
expect(result.isValid).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should reject port 6379 (Redis)', () => {
|
|
332
|
+
const result = validateImageUrl('https://imgur.com:6379/image.jpg');
|
|
333
|
+
expect(result.isValid).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should reject port 9200 (Elasticsearch)', () => {
|
|
337
|
+
const result = validateImageUrl('https://imgur.com:9200/image.jpg');
|
|
338
|
+
expect(result.isValid).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('edge cases and error handling', () => {
|
|
343
|
+
it('should reject empty string', () => {
|
|
344
|
+
const result = validateImageUrl('');
|
|
345
|
+
expect(result.isValid).toBe(false);
|
|
346
|
+
expect(result.error).toContain('Invalid URL format');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should reject malformed URL', () => {
|
|
350
|
+
const result = validateImageUrl('not-a-url');
|
|
351
|
+
expect(result.isValid).toBe(false);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should reject relative URLs', () => {
|
|
355
|
+
const result = validateImageUrl('/path/to/image.jpg');
|
|
356
|
+
expect(result.isValid).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should reject URL without protocol', () => {
|
|
360
|
+
const result = validateImageUrl('imgur.com/image.jpg');
|
|
361
|
+
expect(result.isValid).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should handle URLs with special characters in path', () => {
|
|
365
|
+
const result = validateImageUrl('https://imgur.com/image%20with%20spaces.jpg');
|
|
366
|
+
expect(result.isValid).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should handle URLs with authentication (though domain must be allowed)', () => {
|
|
370
|
+
const result = validateImageUrl('https://user:pass@imgur.com/image.jpg');
|
|
371
|
+
expect(result.isValid).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe('createSecureAxiosConfig', () => {
|
|
377
|
+
it('should return config object with all required fields', () => {
|
|
378
|
+
const url = 'https://imgur.com/image.jpg';
|
|
379
|
+
const config = createSecureAxiosConfig(url);
|
|
380
|
+
|
|
381
|
+
expect(config).toHaveProperty('url', url);
|
|
382
|
+
expect(config).toHaveProperty('responseType', 'stream');
|
|
383
|
+
expect(config).toHaveProperty('timeout', 10000);
|
|
384
|
+
expect(config).toHaveProperty('maxRedirects', 3);
|
|
385
|
+
expect(config).toHaveProperty('maxContentLength', 50 * 1024 * 1024);
|
|
386
|
+
expect(config).toHaveProperty('validateStatus');
|
|
387
|
+
expect(config).toHaveProperty('headers');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('should set correct URL', () => {
|
|
391
|
+
const url = 'https://github.com/user/repo/image.png';
|
|
392
|
+
const config = createSecureAxiosConfig(url);
|
|
393
|
+
|
|
394
|
+
expect(config.url).toBe(url);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should set response type to stream', () => {
|
|
398
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
399
|
+
expect(config.responseType).toBe('stream');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should set 10 second timeout', () => {
|
|
403
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
404
|
+
expect(config.timeout).toBe(10000);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should limit redirects to 3', () => {
|
|
408
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
409
|
+
expect(config.maxRedirects).toBe(3);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('should set max content length to 50MB', () => {
|
|
413
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
414
|
+
expect(config.maxContentLength).toBe(50 * 1024 * 1024);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should include User-Agent header', () => {
|
|
418
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
419
|
+
expect(config.headers).toHaveProperty('User-Agent', 'Ghost-MCP-Server/1.0');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('should have validateStatus function that accepts 2xx status codes', () => {
|
|
423
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
424
|
+
expect(typeof config.validateStatus).toBe('function');
|
|
425
|
+
expect(config.validateStatus(200)).toBe(true);
|
|
426
|
+
expect(config.validateStatus(204)).toBe(true);
|
|
427
|
+
expect(config.validateStatus(299)).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('should have validateStatus function that rejects non-2xx status codes', () => {
|
|
431
|
+
const config = createSecureAxiosConfig('https://imgur.com/image.jpg');
|
|
432
|
+
expect(config.validateStatus(199)).toBe(false);
|
|
433
|
+
expect(config.validateStatus(300)).toBe(false);
|
|
434
|
+
expect(config.validateStatus(404)).toBe(false);
|
|
435
|
+
expect(config.validateStatus(500)).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should handle different URLs independently', () => {
|
|
439
|
+
const url1 = 'https://imgur.com/image1.jpg';
|
|
440
|
+
const url2 = 'https://github.com/image2.png';
|
|
441
|
+
|
|
442
|
+
const config1 = createSecureAxiosConfig(url1);
|
|
443
|
+
const config2 = createSecureAxiosConfig(url2);
|
|
444
|
+
|
|
445
|
+
expect(config1.url).toBe(url1);
|
|
446
|
+
expect(config2.url).toBe(url2);
|
|
447
|
+
expect(config1.url).not.toBe(config2.url);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
});
|