@jgardner04/ghost-mcp-server 1.1.11 → 1.1.13

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.
@@ -0,0 +1,1113 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ // Mock fs/promises
4
+ vi.mock('fs/promises', () => ({
5
+ default: {
6
+ mkdir: vi.fn().mockResolvedValue(undefined),
7
+ stat: vi.fn(),
8
+ rename: vi.fn().mockResolvedValue(undefined),
9
+ appendFile: vi.fn().mockResolvedValue(undefined),
10
+ },
11
+ }));
12
+
13
+ // Mock errors module
14
+ vi.mock('../../errors/index.js', () => ({
15
+ ErrorHandler: {
16
+ isOperationalError: vi.fn((error) => error.isOperational !== false),
17
+ formatHTTPError: vi.fn((error) => ({
18
+ statusCode: error.statusCode || 500,
19
+ body: {
20
+ error: {
21
+ message: error.message,
22
+ code: error.code || 'INTERNAL_ERROR',
23
+ },
24
+ },
25
+ })),
26
+ },
27
+ ValidationError: class ValidationError extends Error {
28
+ constructor(message, errors = []) {
29
+ super(message);
30
+ this.name = 'ValidationError';
31
+ this.statusCode = 400;
32
+ this.errors = errors;
33
+ }
34
+ },
35
+ AuthenticationError: class AuthenticationError extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = 'AuthenticationError';
39
+ this.statusCode = 401;
40
+ }
41
+ },
42
+ RateLimitError: class RateLimitError extends Error {
43
+ constructor(retryAfter) {
44
+ super('Rate limit exceeded');
45
+ this.name = 'RateLimitError';
46
+ this.statusCode = 429;
47
+ this.retryAfter = retryAfter;
48
+ }
49
+ },
50
+ }));
51
+
52
+ import fs from 'fs/promises';
53
+ import {
54
+ ErrorLogger,
55
+ ErrorMetrics,
56
+ expressErrorHandler,
57
+ asyncHandler,
58
+ validateRequest,
59
+ RateLimiter,
60
+ apiKeyAuth,
61
+ mcpCors,
62
+ healthCheck,
63
+ GracefulShutdown,
64
+ errorLogger,
65
+ errorMetrics,
66
+ } from '../errorMiddleware.js';
67
+ import { ErrorHandler, ValidationError, AuthenticationError } from '../../errors/index.js';
68
+
69
+ // Helper to create mock request
70
+ function createMockRequest(overrides = {}) {
71
+ return {
72
+ method: 'GET',
73
+ url: '/test',
74
+ path: '/test',
75
+ ip: '127.0.0.1',
76
+ body: {},
77
+ headers: {},
78
+ get: vi.fn((header) => overrides.headers?.[header.toLowerCase()]),
79
+ socket: {
80
+ on: vi.fn(),
81
+ end: vi.fn(),
82
+ destroy: vi.fn(),
83
+ },
84
+ ...overrides,
85
+ };
86
+ }
87
+
88
+ // Helper to create mock response
89
+ function createMockResponse() {
90
+ const res = {
91
+ status: vi.fn().mockReturnThis(),
92
+ json: vi.fn().mockReturnThis(),
93
+ set: vi.fn().mockReturnThis(),
94
+ header: vi.fn().mockReturnThis(),
95
+ sendStatus: vi.fn().mockReturnThis(),
96
+ };
97
+ return res;
98
+ }
99
+
100
+ // Helper to create mock next function
101
+ function createMockNext() {
102
+ return vi.fn();
103
+ }
104
+
105
+ describe('errorMiddleware', () => {
106
+ beforeEach(() => {
107
+ vi.clearAllMocks();
108
+ vi.useFakeTimers();
109
+ });
110
+
111
+ afterEach(() => {
112
+ vi.useRealTimers();
113
+ });
114
+
115
+ describe('ErrorLogger', () => {
116
+ describe('constructor', () => {
117
+ it('should create logger with default options', () => {
118
+ const logger = new ErrorLogger();
119
+ expect(logger.maxLogSize).toBe(10 * 1024 * 1024);
120
+ expect(logger.logLevel).toBeDefined();
121
+ expect(logger.enableFileLogging).toBe(true);
122
+ });
123
+
124
+ it('should create logger with custom options', () => {
125
+ const logger = new ErrorLogger({
126
+ logDir: '/custom/path',
127
+ maxLogSize: 5 * 1024 * 1024,
128
+ logLevel: 'debug',
129
+ enableFileLogging: false,
130
+ });
131
+ expect(logger.logDir).toBe('/custom/path');
132
+ expect(logger.maxLogSize).toBe(5 * 1024 * 1024);
133
+ expect(logger.logLevel).toBe('debug');
134
+ expect(logger.enableFileLogging).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe('ensureLogDirectory', () => {
139
+ it('should create log directory', async () => {
140
+ fs.mkdir.mockResolvedValue(undefined);
141
+ const logger = new ErrorLogger();
142
+ await logger.ensureLogDirectory();
143
+ expect(fs.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
144
+ });
145
+
146
+ it('should disable file logging on mkdir failure', async () => {
147
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
148
+ fs.mkdir.mockRejectedValue(new Error('Permission denied'));
149
+
150
+ try {
151
+ const logger = new ErrorLogger();
152
+ await logger.ensureLogDirectory();
153
+ expect(logger.enableFileLogging).toBe(false);
154
+ } finally {
155
+ consoleSpy.mockRestore();
156
+ }
157
+ });
158
+ });
159
+
160
+ describe('getLogFilePath', () => {
161
+ it('should return dated log file path', () => {
162
+ const logger = new ErrorLogger();
163
+ const path = logger.getLogFilePath('error');
164
+ const today = new Date().toISOString().split('T')[0];
165
+ expect(path).toContain(`error-${today}.log`);
166
+ });
167
+
168
+ it('should use default type of error', () => {
169
+ const logger = new ErrorLogger();
170
+ const path = logger.getLogFilePath();
171
+ expect(path).toContain('error-');
172
+ });
173
+ });
174
+
175
+ describe('rotateLogIfNeeded', () => {
176
+ it('should rotate log when file exceeds max size', async () => {
177
+ fs.stat.mockResolvedValue({ size: 15 * 1024 * 1024 }); // 15MB
178
+
179
+ const logger = new ErrorLogger();
180
+ await logger.rotateLogIfNeeded('/path/to/error.log');
181
+
182
+ expect(fs.rename).toHaveBeenCalled();
183
+ });
184
+
185
+ it('should not rotate when file is under max size', async () => {
186
+ fs.stat.mockResolvedValue({ size: 5 * 1024 * 1024 }); // 5MB
187
+
188
+ const logger = new ErrorLogger();
189
+ await logger.rotateLogIfNeeded('/path/to/error.log');
190
+
191
+ expect(fs.rename).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it('should handle non-existent file gracefully', async () => {
195
+ fs.stat.mockRejectedValue(new Error('ENOENT'));
196
+
197
+ const logger = new ErrorLogger();
198
+ // Should not throw
199
+ await logger.rotateLogIfNeeded('/path/to/error.log');
200
+ });
201
+ });
202
+
203
+ describe('formatLogEntry', () => {
204
+ it('should format log entry as JSON', () => {
205
+ const logger = new ErrorLogger();
206
+ const entry = logger.formatLogEntry('error', 'Test message', { extra: 'data' });
207
+
208
+ const parsed = JSON.parse(entry.trim());
209
+ expect(parsed.level).toBe('error');
210
+ expect(parsed.message).toBe('Test message');
211
+ expect(parsed.extra).toBe('data');
212
+ expect(parsed.timestamp).toBeDefined();
213
+ expect(parsed.pid).toBe(process.pid);
214
+ });
215
+ });
216
+
217
+ describe('writeToFile', () => {
218
+ it('should write entry to file', async () => {
219
+ fs.stat.mockResolvedValue({ size: 1000 });
220
+
221
+ const logger = new ErrorLogger();
222
+ logger.enableFileLogging = true;
223
+ await logger.writeToFile('error', 'test entry');
224
+
225
+ expect(fs.appendFile).toHaveBeenCalled();
226
+ });
227
+
228
+ it('should not write when file logging is disabled', async () => {
229
+ const logger = new ErrorLogger({ enableFileLogging: false });
230
+ await logger.writeToFile('error', 'test entry');
231
+
232
+ expect(fs.appendFile).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('should handle write errors gracefully', async () => {
236
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
237
+ fs.stat.mockResolvedValue({ size: 1000 });
238
+ fs.appendFile.mockRejectedValue(new Error('Write failed'));
239
+
240
+ const logger = new ErrorLogger();
241
+ logger.enableFileLogging = true;
242
+ await logger.writeToFile('error', 'test entry');
243
+
244
+ expect(consoleSpy).toHaveBeenCalled();
245
+ consoleSpy.mockRestore();
246
+ });
247
+ });
248
+
249
+ describe('logError', () => {
250
+ it('should log operational error', async () => {
251
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
252
+ ErrorHandler.isOperationalError.mockReturnValue(true);
253
+
254
+ const logger = new ErrorLogger();
255
+ logger.enableFileLogging = false;
256
+ const error = new Error('Test error');
257
+ error.statusCode = 400;
258
+
259
+ await logger.logError(error);
260
+
261
+ expect(consoleSpy).toHaveBeenCalledWith(
262
+ expect.stringContaining('[ERROR]'),
263
+ expect.any(String)
264
+ );
265
+ consoleSpy.mockRestore();
266
+ });
267
+
268
+ it('should log fatal error with full details', async () => {
269
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
270
+ ErrorHandler.isOperationalError.mockReturnValue(false);
271
+
272
+ const logger = new ErrorLogger();
273
+ logger.enableFileLogging = false;
274
+ const error = new Error('Fatal error');
275
+
276
+ await logger.logError(error);
277
+
278
+ expect(consoleSpy).toHaveBeenCalledWith(
279
+ expect.stringContaining('[FATAL]'),
280
+ expect.any(String),
281
+ expect.any(Object)
282
+ );
283
+ consoleSpy.mockRestore();
284
+ });
285
+
286
+ it('should include context in log', async () => {
287
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
288
+
289
+ const logger = new ErrorLogger();
290
+ logger.enableFileLogging = false;
291
+ const error = new Error('Test error');
292
+
293
+ await logger.logError(error, { requestId: '123' });
294
+
295
+ expect(consoleSpy).toHaveBeenCalled();
296
+ consoleSpy.mockRestore();
297
+ });
298
+ });
299
+
300
+ describe('logInfo', () => {
301
+ it('should log info message when level allows', async () => {
302
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
303
+
304
+ const logger = new ErrorLogger({ logLevel: 'info', enableFileLogging: false });
305
+ await logger.logInfo('Info message');
306
+
307
+ expect(consoleSpy).toHaveBeenCalledWith('[INFO] Info message');
308
+ consoleSpy.mockRestore();
309
+ });
310
+
311
+ it('should not log when level is higher', async () => {
312
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
313
+
314
+ const logger = new ErrorLogger({ logLevel: 'error', enableFileLogging: false });
315
+ await logger.logInfo('Info message');
316
+
317
+ expect(consoleSpy).not.toHaveBeenCalled();
318
+ consoleSpy.mockRestore();
319
+ });
320
+ });
321
+
322
+ describe('logWarning', () => {
323
+ it('should log warning message when level allows', async () => {
324
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
325
+
326
+ const logger = new ErrorLogger({ logLevel: 'warning', enableFileLogging: false });
327
+ await logger.logWarning('Warning message');
328
+
329
+ expect(consoleSpy).toHaveBeenCalledWith('[WARNING] Warning message');
330
+ consoleSpy.mockRestore();
331
+ });
332
+ });
333
+
334
+ describe('logDebug', () => {
335
+ it('should log debug message when level is debug', async () => {
336
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
337
+
338
+ const logger = new ErrorLogger({ logLevel: 'debug', enableFileLogging: false });
339
+ await logger.logDebug('Debug message');
340
+
341
+ expect(consoleSpy).toHaveBeenCalledWith('[DEBUG] Debug message');
342
+ consoleSpy.mockRestore();
343
+ });
344
+
345
+ it('should not log when level is not debug', async () => {
346
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
347
+
348
+ const logger = new ErrorLogger({ logLevel: 'info', enableFileLogging: false });
349
+ await logger.logDebug('Debug message');
350
+
351
+ expect(consoleSpy).not.toHaveBeenCalled();
352
+ consoleSpy.mockRestore();
353
+ });
354
+ });
355
+ });
356
+
357
+ describe('ErrorMetrics', () => {
358
+ describe('constructor', () => {
359
+ it('should initialize with default metrics', () => {
360
+ const metrics = new ErrorMetrics();
361
+ expect(metrics.metrics.totalErrors).toBe(0);
362
+ expect(metrics.metrics.errorsByType).toEqual({});
363
+ expect(metrics.metrics.errorsByStatusCode).toEqual({});
364
+ expect(metrics.metrics.errorsByEndpoint).toEqual({});
365
+ });
366
+ });
367
+
368
+ describe('recordError', () => {
369
+ it('should increment total errors', () => {
370
+ const metrics = new ErrorMetrics();
371
+ metrics.recordError(new Error('Test'));
372
+ expect(metrics.metrics.totalErrors).toBe(1);
373
+ });
374
+
375
+ it('should count errors by type', () => {
376
+ const metrics = new ErrorMetrics();
377
+ const error = new ValidationError('Test');
378
+ metrics.recordError(error);
379
+ expect(metrics.metrics.errorsByType['ValidationError']).toBe(1);
380
+ });
381
+
382
+ it('should count errors by status code', () => {
383
+ const metrics = new ErrorMetrics();
384
+ const error = new Error('Test');
385
+ error.statusCode = 400;
386
+ metrics.recordError(error);
387
+ expect(metrics.metrics.errorsByStatusCode[400]).toBe(1);
388
+ });
389
+
390
+ it('should default status code to 500', () => {
391
+ const metrics = new ErrorMetrics();
392
+ metrics.recordError(new Error('Test'));
393
+ expect(metrics.metrics.errorsByStatusCode[500]).toBe(1);
394
+ });
395
+
396
+ it('should count errors by endpoint when provided', () => {
397
+ const metrics = new ErrorMetrics();
398
+ metrics.recordError(new Error('Test'), 'GET /api/test');
399
+ expect(metrics.metrics.errorsByEndpoint['GET /api/test']).toBe(1);
400
+ });
401
+
402
+ it('should not count endpoint when not provided', () => {
403
+ const metrics = new ErrorMetrics();
404
+ metrics.recordError(new Error('Test'));
405
+ expect(Object.keys(metrics.metrics.errorsByEndpoint)).toHaveLength(0);
406
+ });
407
+ });
408
+
409
+ describe('getMetrics', () => {
410
+ it('should return metrics with system info', () => {
411
+ const metrics = new ErrorMetrics();
412
+ const result = metrics.getMetrics();
413
+
414
+ expect(result.totalErrors).toBe(0);
415
+ expect(result.uptime).toBeDefined();
416
+ expect(result.memoryUsage).toBeDefined();
417
+ expect(result.timestamp).toBeDefined();
418
+ });
419
+ });
420
+
421
+ describe('reset', () => {
422
+ it('should reset all metrics', () => {
423
+ const metrics = new ErrorMetrics();
424
+ metrics.recordError(new Error('Test'));
425
+ metrics.reset();
426
+
427
+ expect(metrics.metrics.totalErrors).toBe(0);
428
+ expect(metrics.metrics.errorsByType).toEqual({});
429
+ });
430
+
431
+ it('should update lastReset timestamp', () => {
432
+ const metrics = new ErrorMetrics();
433
+ const originalReset = metrics.metrics.lastReset;
434
+
435
+ vi.advanceTimersByTime(1000);
436
+ metrics.reset();
437
+
438
+ expect(metrics.metrics.lastReset).not.toBe(originalReset);
439
+ });
440
+ });
441
+ });
442
+
443
+ describe('expressErrorHandler', () => {
444
+ it('should log error and record metrics', () => {
445
+ const req = createMockRequest();
446
+ const res = createMockResponse();
447
+ const next = createMockNext();
448
+ const error = new Error('Test error');
449
+
450
+ expressErrorHandler(error, req, res, next);
451
+
452
+ expect(res.status).toHaveBeenCalledWith(500);
453
+ expect(res.json).toHaveBeenCalled();
454
+ });
455
+
456
+ it('should set security headers', () => {
457
+ const req = createMockRequest();
458
+ const res = createMockResponse();
459
+ const next = createMockNext();
460
+ const error = new Error('Test error');
461
+
462
+ expressErrorHandler(error, req, res, next);
463
+
464
+ expect(res.set).toHaveBeenCalledWith(
465
+ expect.objectContaining({
466
+ 'X-Content-Type-Options': 'nosniff',
467
+ 'X-Frame-Options': 'DENY',
468
+ 'X-XSS-Protection': '1; mode=block',
469
+ })
470
+ );
471
+ });
472
+
473
+ it('should use error statusCode when available', () => {
474
+ const req = createMockRequest();
475
+ const res = createMockResponse();
476
+ const next = createMockNext();
477
+ const error = new Error('Bad request');
478
+ error.statusCode = 400;
479
+
480
+ ErrorHandler.formatHTTPError.mockReturnValue({
481
+ statusCode: 400,
482
+ body: { error: { message: 'Bad request' } },
483
+ });
484
+
485
+ expressErrorHandler(error, req, res, next);
486
+
487
+ expect(res.status).toHaveBeenCalledWith(400);
488
+ });
489
+ });
490
+
491
+ describe('asyncHandler', () => {
492
+ it('should call next with error when async function rejects', async () => {
493
+ const error = new Error('Async error');
494
+ const asyncFn = vi.fn().mockRejectedValue(error);
495
+ const handler = asyncHandler(asyncFn);
496
+
497
+ const req = createMockRequest();
498
+ const res = createMockResponse();
499
+ const next = createMockNext();
500
+
501
+ await handler(req, res, next);
502
+
503
+ expect(next).toHaveBeenCalledWith(error);
504
+ });
505
+
506
+ it('should not call next when async function resolves', async () => {
507
+ const asyncFn = vi.fn().mockResolvedValue(undefined);
508
+ const handler = asyncHandler(asyncFn);
509
+
510
+ const req = createMockRequest();
511
+ const res = createMockResponse();
512
+ const next = createMockNext();
513
+
514
+ await handler(req, res, next);
515
+
516
+ expect(next).not.toHaveBeenCalled();
517
+ });
518
+
519
+ it('should pass req, res, next to wrapped function', async () => {
520
+ const asyncFn = vi.fn().mockResolvedValue(undefined);
521
+ const handler = asyncHandler(asyncFn);
522
+
523
+ const req = createMockRequest();
524
+ const res = createMockResponse();
525
+ const next = createMockNext();
526
+
527
+ await handler(req, res, next);
528
+
529
+ expect(asyncFn).toHaveBeenCalledWith(req, res, next);
530
+ });
531
+ });
532
+
533
+ describe('validateRequest', () => {
534
+ describe('with function schema', () => {
535
+ it('should call next when validation passes', () => {
536
+ const schema = vi.fn().mockReturnValue({ error: null });
537
+ const middleware = validateRequest(schema);
538
+
539
+ const req = createMockRequest({ body: { name: 'test' } });
540
+ const res = createMockResponse();
541
+ const next = createMockNext();
542
+
543
+ middleware(req, res, next);
544
+
545
+ expect(next).toHaveBeenCalledWith();
546
+ });
547
+
548
+ it('should call next with error when validation fails', () => {
549
+ const schema = vi.fn().mockReturnValue({ error: 'Invalid input' });
550
+ const middleware = validateRequest(schema);
551
+
552
+ const req = createMockRequest({ body: {} });
553
+ const res = createMockResponse();
554
+ const next = createMockNext();
555
+
556
+ middleware(req, res, next);
557
+
558
+ expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
559
+ });
560
+ });
561
+
562
+ describe('with Joi-like schema', () => {
563
+ it('should call next when validation passes', () => {
564
+ const schema = {
565
+ validate: vi.fn().mockReturnValue({ error: null }),
566
+ };
567
+ const middleware = validateRequest(schema);
568
+
569
+ const req = createMockRequest({ body: { name: 'test' } });
570
+ const res = createMockResponse();
571
+ const next = createMockNext();
572
+
573
+ middleware(req, res, next);
574
+
575
+ expect(next).toHaveBeenCalledWith();
576
+ });
577
+
578
+ it('should call next with ValidationError when validation fails', () => {
579
+ const schema = {
580
+ validate: vi.fn().mockReturnValue({
581
+ error: {
582
+ details: [{ message: 'Name is required' }],
583
+ },
584
+ }),
585
+ };
586
+ const middleware = validateRequest(schema);
587
+
588
+ const req = createMockRequest({ body: {} });
589
+ const res = createMockResponse();
590
+ const next = createMockNext();
591
+
592
+ middleware(req, res, next);
593
+
594
+ expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
595
+ });
596
+ });
597
+
598
+ describe('with object schema', () => {
599
+ it('should call next when required fields are present', () => {
600
+ const schema = {
601
+ name: { required: true, type: 'string' },
602
+ };
603
+ const middleware = validateRequest(schema);
604
+
605
+ const req = createMockRequest({ body: { name: 'test' } });
606
+ const res = createMockResponse();
607
+ const next = createMockNext();
608
+
609
+ middleware(req, res, next);
610
+
611
+ expect(next).toHaveBeenCalledWith();
612
+ });
613
+
614
+ it('should call next with error when required field is missing', () => {
615
+ const schema = {
616
+ name: { required: true },
617
+ };
618
+ const middleware = validateRequest(schema);
619
+
620
+ const req = createMockRequest({ body: {} });
621
+ const res = createMockResponse();
622
+ const next = createMockNext();
623
+
624
+ middleware(req, res, next);
625
+
626
+ expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
627
+ });
628
+
629
+ it('should call next with error when type is wrong', () => {
630
+ const schema = {
631
+ name: { type: 'string' },
632
+ };
633
+ const middleware = validateRequest(schema);
634
+
635
+ const req = createMockRequest({ body: { name: 123 } });
636
+ const res = createMockResponse();
637
+ const next = createMockNext();
638
+
639
+ middleware(req, res, next);
640
+
641
+ expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
642
+ });
643
+ });
644
+ });
645
+
646
+ describe('RateLimiter', () => {
647
+ describe('constructor', () => {
648
+ it('should use default options', () => {
649
+ const limiter = new RateLimiter();
650
+ expect(limiter.windowMs).toBe(60000);
651
+ expect(limiter.maxRequests).toBe(100);
652
+ });
653
+
654
+ it('should accept custom options', () => {
655
+ const limiter = new RateLimiter({
656
+ windowMs: 30000,
657
+ maxRequests: 50,
658
+ });
659
+ expect(limiter.windowMs).toBe(30000);
660
+ expect(limiter.maxRequests).toBe(50);
661
+ });
662
+ });
663
+
664
+ describe('middleware', () => {
665
+ it('should allow requests under limit', () => {
666
+ const limiter = new RateLimiter({ maxRequests: 5 });
667
+ const middleware = limiter.middleware();
668
+
669
+ const req = createMockRequest();
670
+ const res = createMockResponse();
671
+ const next = createMockNext();
672
+
673
+ middleware(req, res, next);
674
+
675
+ expect(next).toHaveBeenCalledWith();
676
+ });
677
+
678
+ it('should block requests over limit', () => {
679
+ const limiter = new RateLimiter({ maxRequests: 2 });
680
+ const middleware = limiter.middleware();
681
+
682
+ const req = createMockRequest();
683
+ const res = createMockResponse();
684
+ const next = createMockNext();
685
+
686
+ // First two requests should pass
687
+ middleware(req, res, next);
688
+ middleware(req, res, next);
689
+ expect(next).toHaveBeenCalledTimes(2);
690
+
691
+ // Third request should be rate limited
692
+ middleware(req, res, next);
693
+ expect(next).toHaveBeenLastCalledWith(expect.any(Error));
694
+ });
695
+
696
+ it('should reset after window expires', () => {
697
+ const limiter = new RateLimiter({ maxRequests: 1, windowMs: 1000 });
698
+ const middleware = limiter.middleware();
699
+
700
+ const req = createMockRequest();
701
+ const res = createMockResponse();
702
+ const next = createMockNext();
703
+
704
+ middleware(req, res, next);
705
+ expect(next).toHaveBeenCalledWith();
706
+
707
+ // Advance time past window
708
+ vi.advanceTimersByTime(1500);
709
+
710
+ next.mockClear();
711
+ middleware(req, res, next);
712
+ expect(next).toHaveBeenCalledWith();
713
+ });
714
+
715
+ it('should track requests by IP', () => {
716
+ const limiter = new RateLimiter({ maxRequests: 1 });
717
+ const middleware = limiter.middleware();
718
+
719
+ const req1 = createMockRequest({ ip: '1.1.1.1' });
720
+ const req2 = createMockRequest({ ip: '2.2.2.2' });
721
+ const res = createMockResponse();
722
+ const next = createMockNext();
723
+
724
+ middleware(req1, res, next);
725
+ middleware(req2, res, next);
726
+
727
+ // Both should pass because they're different IPs
728
+ expect(next).toHaveBeenCalledTimes(2);
729
+ expect(next).toHaveBeenNthCalledWith(1);
730
+ expect(next).toHaveBeenNthCalledWith(2);
731
+ });
732
+ });
733
+
734
+ describe('cleanup', () => {
735
+ it('should remove old entries', () => {
736
+ const limiter = new RateLimiter({ windowMs: 1000 });
737
+
738
+ // Add some requests
739
+ limiter.requests.set('1.1.1.1', [Date.now() - 2000]);
740
+ limiter.requests.set('2.2.2.2', [Date.now()]);
741
+
742
+ limiter.cleanup(Date.now());
743
+
744
+ expect(limiter.requests.has('1.1.1.1')).toBe(false);
745
+ expect(limiter.requests.has('2.2.2.2')).toBe(true);
746
+ });
747
+ });
748
+ });
749
+
750
+ describe('apiKeyAuth', () => {
751
+ it('should call next when API key is valid', () => {
752
+ const middleware = apiKeyAuth('test-api-key');
753
+
754
+ const req = createMockRequest({
755
+ headers: { 'x-api-key': 'test-api-key' },
756
+ });
757
+ req.get = vi.fn().mockReturnValue(null);
758
+ req.headers = { 'x-api-key': 'test-api-key' };
759
+ const res = createMockResponse();
760
+ const next = createMockNext();
761
+
762
+ middleware(req, res, next);
763
+
764
+ expect(next).toHaveBeenCalledWith();
765
+ });
766
+
767
+ it('should call next with error when API key is missing', () => {
768
+ const middleware = apiKeyAuth('test-api-key');
769
+
770
+ const req = createMockRequest();
771
+ req.get = vi.fn().mockReturnValue(null);
772
+ req.headers = {};
773
+ const res = createMockResponse();
774
+ const next = createMockNext();
775
+
776
+ middleware(req, res, next);
777
+
778
+ expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
779
+ });
780
+
781
+ it('should call next with error when API key is invalid', () => {
782
+ const middleware = apiKeyAuth('test-api-key');
783
+
784
+ const req = createMockRequest();
785
+ req.get = vi.fn().mockReturnValue(null);
786
+ req.headers = { 'x-api-key': 'wrong-key' };
787
+ const res = createMockResponse();
788
+ const next = createMockNext();
789
+
790
+ middleware(req, res, next);
791
+
792
+ expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
793
+ });
794
+
795
+ it('should accept Bearer token in authorization header', () => {
796
+ const middleware = apiKeyAuth('test-api-key');
797
+
798
+ const req = createMockRequest();
799
+ req.get = vi.fn().mockReturnValue(null);
800
+ req.headers = { authorization: 'Bearer test-api-key' };
801
+ const res = createMockResponse();
802
+ const next = createMockNext();
803
+
804
+ middleware(req, res, next);
805
+
806
+ expect(next).toHaveBeenCalledWith();
807
+ });
808
+
809
+ it('should reject keys of different length', () => {
810
+ const middleware = apiKeyAuth('test-api-key');
811
+
812
+ const req = createMockRequest();
813
+ req.get = vi.fn().mockReturnValue(null);
814
+ req.headers = { 'x-api-key': 'short' };
815
+ const res = createMockResponse();
816
+ const next = createMockNext();
817
+
818
+ middleware(req, res, next);
819
+
820
+ expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
821
+ });
822
+ });
823
+
824
+ describe('mcpCors', () => {
825
+ it('should set CORS headers for allowed origin', () => {
826
+ const middleware = mcpCors(['http://localhost:3000']);
827
+
828
+ const req = createMockRequest({
829
+ headers: { origin: 'http://localhost:3000' },
830
+ method: 'GET',
831
+ });
832
+ const res = createMockResponse();
833
+ const next = createMockNext();
834
+
835
+ middleware(req, res, next);
836
+
837
+ expect(res.header).toHaveBeenCalledWith(
838
+ 'Access-Control-Allow-Origin',
839
+ 'http://localhost:3000'
840
+ );
841
+ expect(next).toHaveBeenCalled();
842
+ });
843
+
844
+ it('should allow all origins when wildcard is used', () => {
845
+ const middleware = mcpCors(['*']);
846
+
847
+ const req = createMockRequest({
848
+ headers: { origin: 'http://any-origin.com' },
849
+ method: 'GET',
850
+ });
851
+ const res = createMockResponse();
852
+ const next = createMockNext();
853
+
854
+ middleware(req, res, next);
855
+
856
+ expect(res.header).toHaveBeenCalledWith(
857
+ 'Access-Control-Allow-Origin',
858
+ 'http://any-origin.com'
859
+ );
860
+ });
861
+
862
+ it('should handle OPTIONS preflight request', () => {
863
+ const middleware = mcpCors(['*']);
864
+
865
+ const req = createMockRequest({
866
+ method: 'OPTIONS',
867
+ });
868
+ const res = createMockResponse();
869
+ const next = createMockNext();
870
+
871
+ middleware(req, res, next);
872
+
873
+ expect(res.sendStatus).toHaveBeenCalledWith(204);
874
+ expect(next).not.toHaveBeenCalled();
875
+ });
876
+
877
+ it('should set all required CORS headers', () => {
878
+ const middleware = mcpCors(['*']);
879
+
880
+ const req = createMockRequest({ method: 'GET' });
881
+ const res = createMockResponse();
882
+ const next = createMockNext();
883
+
884
+ middleware(req, res, next);
885
+
886
+ expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
887
+ expect(res.header).toHaveBeenCalledWith(
888
+ 'Access-Control-Allow-Headers',
889
+ 'Content-Type, Authorization, X-API-Key'
890
+ );
891
+ expect(res.header).toHaveBeenCalledWith('Access-Control-Max-Age', '86400');
892
+ });
893
+ });
894
+
895
+ describe('healthCheck', () => {
896
+ it('should return healthy status when service is healthy', async () => {
897
+ const mockGhostService = {
898
+ checkHealth: vi.fn().mockResolvedValue({ status: 'healthy' }),
899
+ };
900
+ const handler = healthCheck(mockGhostService);
901
+
902
+ const req = createMockRequest();
903
+ const res = createMockResponse();
904
+
905
+ await handler(req, res);
906
+
907
+ expect(res.status).toHaveBeenCalledWith(200);
908
+ expect(res.json).toHaveBeenCalledWith(
909
+ expect.objectContaining({
910
+ status: 'healthy',
911
+ })
912
+ );
913
+ });
914
+
915
+ it('should return unhealthy status when service is unhealthy', async () => {
916
+ const mockGhostService = {
917
+ checkHealth: vi.fn().mockResolvedValue({ status: 'unhealthy' }),
918
+ };
919
+ const handler = healthCheck(mockGhostService);
920
+
921
+ const req = createMockRequest();
922
+ const res = createMockResponse();
923
+
924
+ await handler(req, res);
925
+
926
+ expect(res.status).toHaveBeenCalledWith(503);
927
+ });
928
+
929
+ it('should handle health check errors', async () => {
930
+ const mockGhostService = {
931
+ checkHealth: vi.fn().mockRejectedValue(new Error('Connection failed')),
932
+ };
933
+ const handler = healthCheck(mockGhostService);
934
+
935
+ const req = createMockRequest();
936
+ const res = createMockResponse();
937
+
938
+ await handler(req, res);
939
+
940
+ expect(res.status).toHaveBeenCalledWith(503);
941
+ expect(res.json).toHaveBeenCalledWith(
942
+ expect.objectContaining({
943
+ status: 'unhealthy',
944
+ error: 'Connection failed',
945
+ })
946
+ );
947
+ });
948
+
949
+ it('should include metrics in response', async () => {
950
+ const mockGhostService = {
951
+ checkHealth: vi.fn().mockResolvedValue({ status: 'healthy' }),
952
+ };
953
+ const handler = healthCheck(mockGhostService);
954
+
955
+ const req = createMockRequest();
956
+ const res = createMockResponse();
957
+
958
+ await handler(req, res);
959
+
960
+ expect(res.json).toHaveBeenCalledWith(
961
+ expect.objectContaining({
962
+ metrics: expect.objectContaining({
963
+ errors: expect.any(Number),
964
+ uptime: expect.any(Number),
965
+ }),
966
+ })
967
+ );
968
+ });
969
+ });
970
+
971
+ describe('GracefulShutdown', () => {
972
+ describe('constructor', () => {
973
+ it('should initialize with default values', () => {
974
+ const shutdown = new GracefulShutdown();
975
+ expect(shutdown.isShuttingDown).toBe(false);
976
+ expect(shutdown.connections.size).toBe(0);
977
+ });
978
+ });
979
+
980
+ describe('trackConnection', () => {
981
+ it('should add connection to set', () => {
982
+ const shutdown = new GracefulShutdown();
983
+ const mockConnection = { on: vi.fn() };
984
+
985
+ shutdown.trackConnection(mockConnection);
986
+
987
+ expect(shutdown.connections.has(mockConnection)).toBe(true);
988
+ });
989
+
990
+ it('should remove connection on close event', () => {
991
+ const shutdown = new GracefulShutdown();
992
+ const mockConnection = { on: vi.fn() };
993
+
994
+ shutdown.trackConnection(mockConnection);
995
+
996
+ // Simulate close event
997
+ const closeCallback = mockConnection.on.mock.calls.find((c) => c[0] === 'close')[1];
998
+ closeCallback();
999
+
1000
+ expect(shutdown.connections.has(mockConnection)).toBe(false);
1001
+ });
1002
+ });
1003
+
1004
+ describe('middleware', () => {
1005
+ it('should call next when not shutting down', () => {
1006
+ const shutdown = new GracefulShutdown();
1007
+ const middleware = shutdown.middleware();
1008
+
1009
+ const req = createMockRequest();
1010
+ const res = createMockResponse();
1011
+ const next = createMockNext();
1012
+
1013
+ middleware(req, res, next);
1014
+
1015
+ expect(next).toHaveBeenCalled();
1016
+ });
1017
+
1018
+ it('should return 503 when shutting down', () => {
1019
+ const shutdown = new GracefulShutdown();
1020
+ shutdown.isShuttingDown = true;
1021
+ const middleware = shutdown.middleware();
1022
+
1023
+ const req = createMockRequest();
1024
+ const res = createMockResponse();
1025
+ const next = createMockNext();
1026
+
1027
+ middleware(req, res, next);
1028
+
1029
+ expect(res.status).toHaveBeenCalledWith(503);
1030
+ expect(res.set).toHaveBeenCalledWith('Connection', 'close');
1031
+ expect(next).not.toHaveBeenCalled();
1032
+ });
1033
+
1034
+ it('should track connection', () => {
1035
+ const shutdown = new GracefulShutdown();
1036
+ const middleware = shutdown.middleware();
1037
+
1038
+ const req = createMockRequest();
1039
+ const res = createMockResponse();
1040
+ const next = createMockNext();
1041
+
1042
+ middleware(req, res, next);
1043
+
1044
+ expect(shutdown.connections.has(req.socket)).toBe(true);
1045
+ });
1046
+ });
1047
+
1048
+ describe('shutdown', () => {
1049
+ it('should set isShuttingDown flag', async () => {
1050
+ const shutdown = new GracefulShutdown();
1051
+ const mockServer = { close: vi.fn() };
1052
+
1053
+ // Don't await - just start shutdown
1054
+ shutdown.shutdown(mockServer);
1055
+
1056
+ expect(shutdown.isShuttingDown).toBe(true);
1057
+ });
1058
+
1059
+ it('should close server', async () => {
1060
+ const shutdown = new GracefulShutdown();
1061
+ const mockServer = { close: vi.fn((cb) => cb()) };
1062
+
1063
+ shutdown.shutdown(mockServer);
1064
+
1065
+ expect(mockServer.close).toHaveBeenCalled();
1066
+ });
1067
+
1068
+ it('should end all connections', async () => {
1069
+ const shutdown = new GracefulShutdown();
1070
+ const mockConnection = { on: vi.fn(), end: vi.fn(), destroy: vi.fn() };
1071
+ shutdown.trackConnection(mockConnection);
1072
+
1073
+ const mockServer = { close: vi.fn() };
1074
+ shutdown.shutdown(mockServer);
1075
+
1076
+ expect(mockConnection.end).toHaveBeenCalled();
1077
+ });
1078
+
1079
+ it('should force destroy connections after timeout', async () => {
1080
+ const shutdown = new GracefulShutdown();
1081
+ const mockConnection = { on: vi.fn(), end: vi.fn(), destroy: vi.fn() };
1082
+ shutdown.trackConnection(mockConnection);
1083
+
1084
+ const mockServer = { close: vi.fn() };
1085
+ shutdown.shutdown(mockServer);
1086
+
1087
+ vi.advanceTimersByTime(10000);
1088
+
1089
+ expect(mockConnection.destroy).toHaveBeenCalled();
1090
+ });
1091
+
1092
+ it('should not shutdown twice', async () => {
1093
+ const shutdown = new GracefulShutdown();
1094
+ shutdown.isShuttingDown = true;
1095
+
1096
+ const mockServer = { close: vi.fn() };
1097
+ await shutdown.shutdown(mockServer);
1098
+
1099
+ expect(mockServer.close).not.toHaveBeenCalled();
1100
+ });
1101
+ });
1102
+ });
1103
+
1104
+ describe('global instances', () => {
1105
+ it('should export errorLogger instance', () => {
1106
+ expect(errorLogger).toBeInstanceOf(ErrorLogger);
1107
+ });
1108
+
1109
+ it('should export errorMetrics instance', () => {
1110
+ expect(errorMetrics).toBeInstanceOf(ErrorMetrics);
1111
+ });
1112
+ });
1113
+ });