@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.
- package/package.json +2 -1
- package/src/__tests__/helpers/mockExpress.js +38 -0
- package/src/__tests__/index.test.js +312 -0
- package/src/__tests__/mcp_server.test.js +381 -0
- package/src/config/__tests__/mcp-config.test.js +311 -0
- package/src/controllers/__tests__/imageController.test.js +572 -0
- package/src/controllers/__tests__/postController.test.js +236 -0
- package/src/controllers/__tests__/tagController.test.js +222 -0
- package/src/middleware/__tests__/errorMiddleware.test.js +1113 -0
- package/src/resources/__tests__/ResourceManager.test.js +977 -0
- package/src/routes/__tests__/imageRoutes.test.js +117 -0
- package/src/routes/__tests__/postRoutes.test.js +262 -0
- package/src/routes/__tests__/tagRoutes.test.js +175 -0
- package/src/utils/__tests__/logger.test.js +512 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgardner04/ghost-mcp-server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.13",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
|
|
5
5
|
"author": "Jonathan Gardner",
|
|
6
6
|
"type": "module",
|
|
@@ -106,6 +106,7 @@
|
|
|
106
106
|
"lint-staged": "^16.2.7",
|
|
107
107
|
"prettier": "^3.7.4",
|
|
108
108
|
"semantic-release": "^25.0.2",
|
|
109
|
+
"supertest": "^7.1.4",
|
|
109
110
|
"vitest": "^4.0.15"
|
|
110
111
|
}
|
|
111
112
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mock helpers for Express req, res, next
|
|
5
|
+
* Used in controller tests
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a mock Express request object
|
|
10
|
+
* @param {Object} options - Request options
|
|
11
|
+
* @param {Object} options.query - Query parameters
|
|
12
|
+
* @param {Object} options.body - Request body
|
|
13
|
+
* @param {Object} options.params - Route parameters
|
|
14
|
+
* @returns {Object} Mock request object
|
|
15
|
+
*/
|
|
16
|
+
export const createMockRequest = ({ query = {}, body = {}, params = {} } = {}) => ({
|
|
17
|
+
query,
|
|
18
|
+
body,
|
|
19
|
+
params,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates a mock Express response object
|
|
24
|
+
* @returns {Object} Mock response object with spied methods
|
|
25
|
+
*/
|
|
26
|
+
export const createMockResponse = () => {
|
|
27
|
+
const res = {};
|
|
28
|
+
res.status = vi.fn().mockReturnValue(res);
|
|
29
|
+
res.json = vi.fn().mockReturnValue(res);
|
|
30
|
+
res.send = vi.fn().mockReturnValue(res);
|
|
31
|
+
return res;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a mock Express next function
|
|
36
|
+
* @returns {Function} Mock next function
|
|
37
|
+
*/
|
|
38
|
+
export const createMockNext = () => vi.fn();
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import helmet from 'helmet';
|
|
5
|
+
|
|
6
|
+
// Mock dependencies before importing
|
|
7
|
+
vi.mock('dotenv', () => ({
|
|
8
|
+
default: { config: vi.fn() },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('../utils/logger.js', () => ({
|
|
12
|
+
createContextLogger: vi.fn().mockReturnValue({
|
|
13
|
+
info: vi.fn(),
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
warn: vi.fn(),
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Create a mock app for testing (since index.js starts servers on import)
|
|
21
|
+
function createTestApp() {
|
|
22
|
+
const app = express();
|
|
23
|
+
|
|
24
|
+
app.use(
|
|
25
|
+
helmet({
|
|
26
|
+
contentSecurityPolicy: {
|
|
27
|
+
directives: {
|
|
28
|
+
defaultSrc: ["'self'"],
|
|
29
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
30
|
+
scriptSrc: ["'self'"],
|
|
31
|
+
imgSrc: ["'self'", 'data:', 'https:'],
|
|
32
|
+
connectSrc: ["'self'"],
|
|
33
|
+
fontSrc: ["'self'"],
|
|
34
|
+
objectSrc: ["'none'"],
|
|
35
|
+
mediaSrc: ["'self'"],
|
|
36
|
+
frameSrc: ["'none'"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
hsts: {
|
|
40
|
+
maxAge: 31536000,
|
|
41
|
+
includeSubDomains: true,
|
|
42
|
+
preload: true,
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
app.use(express.json({ limit: '1mb', strict: true, type: 'application/json' }));
|
|
48
|
+
app.use(express.urlencoded({ extended: true, limit: '1mb', parameterLimit: 100 }));
|
|
49
|
+
|
|
50
|
+
// Health check endpoint
|
|
51
|
+
app.get('/health', (req, res) => {
|
|
52
|
+
res.status(200).json({ status: 'ok', message: 'Server is running' });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Mock routes
|
|
56
|
+
app.use('/api/posts', (req, res) => res.json({ route: 'posts' }));
|
|
57
|
+
app.use('/api/images', (req, res) => res.json({ route: 'images' }));
|
|
58
|
+
app.use('/api/tags', (req, res) => res.json({ route: 'tags' }));
|
|
59
|
+
|
|
60
|
+
// Global error handler
|
|
61
|
+
app.use((err, req, res, _next) => {
|
|
62
|
+
const statusCode = err.statusCode || err.response?.status || 500;
|
|
63
|
+
res.status(statusCode).json({
|
|
64
|
+
message: err.message || 'Internal Server Error',
|
|
65
|
+
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return app;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('index.js Express Application', () => {
|
|
73
|
+
let app;
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
app = createTestApp();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('Health Check Endpoint', () => {
|
|
80
|
+
it('should return 200 status on /health', async () => {
|
|
81
|
+
const response = await request(app).get('/health');
|
|
82
|
+
expect(response.status).toBe(200);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should return ok status in response body', async () => {
|
|
86
|
+
const response = await request(app).get('/health');
|
|
87
|
+
expect(response.body.status).toBe('ok');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should return server running message', async () => {
|
|
91
|
+
const response = await request(app).get('/health');
|
|
92
|
+
expect(response.body.message).toBe('Server is running');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('Security Headers (Helmet)', () => {
|
|
97
|
+
it('should set X-Content-Type-Options header', async () => {
|
|
98
|
+
const response = await request(app).get('/health');
|
|
99
|
+
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should set X-Frame-Options header', async () => {
|
|
103
|
+
const response = await request(app).get('/health');
|
|
104
|
+
expect(response.headers['x-frame-options']).toBe('SAMEORIGIN');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should set Strict-Transport-Security header', async () => {
|
|
108
|
+
const response = await request(app).get('/health');
|
|
109
|
+
expect(response.headers['strict-transport-security']).toContain('max-age=31536000');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should set Content-Security-Policy header', async () => {
|
|
113
|
+
const response = await request(app).get('/health');
|
|
114
|
+
expect(response.headers['content-security-policy']).toBeDefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('Body Parser Middleware', () => {
|
|
119
|
+
it('should parse JSON body', async () => {
|
|
120
|
+
// Create app with test route
|
|
121
|
+
const testApp = express();
|
|
122
|
+
testApp.use(express.json({ limit: '1mb' }));
|
|
123
|
+
testApp.post('/test', (req, res) => {
|
|
124
|
+
res.json({ received: req.body });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const response = await request(testApp)
|
|
128
|
+
.post('/test')
|
|
129
|
+
.set('Content-Type', 'application/json')
|
|
130
|
+
.send({ name: 'test' });
|
|
131
|
+
|
|
132
|
+
expect(response.body.received).toEqual({ name: 'test' });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should parse URL-encoded body', async () => {
|
|
136
|
+
const testApp = express();
|
|
137
|
+
testApp.use(express.urlencoded({ extended: true }));
|
|
138
|
+
testApp.post('/test', (req, res) => {
|
|
139
|
+
res.json({ received: req.body });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const response = await request(testApp)
|
|
143
|
+
.post('/test')
|
|
144
|
+
.set('Content-Type', 'application/x-www-form-urlencoded')
|
|
145
|
+
.send('name=test&value=123');
|
|
146
|
+
|
|
147
|
+
expect(response.body.received).toEqual({ name: 'test', value: '123' });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should reject JSON body larger than 1mb', async () => {
|
|
151
|
+
const testApp = express();
|
|
152
|
+
testApp.use(express.json({ limit: '1mb' }));
|
|
153
|
+
testApp.post('/test', (req, res) => {
|
|
154
|
+
res.json({ received: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Create a large payload (>1MB)
|
|
158
|
+
const largePayload = { data: 'x'.repeat(1024 * 1024 + 1000) };
|
|
159
|
+
|
|
160
|
+
const response = await request(testApp)
|
|
161
|
+
.post('/test')
|
|
162
|
+
.set('Content-Type', 'application/json')
|
|
163
|
+
.send(largePayload);
|
|
164
|
+
|
|
165
|
+
expect(response.status).toBe(413);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('Route Mounting', () => {
|
|
170
|
+
it('should mount post routes at /api/posts', async () => {
|
|
171
|
+
const response = await request(app).get('/api/posts');
|
|
172
|
+
expect(response.body.route).toBe('posts');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should mount image routes at /api/images', async () => {
|
|
176
|
+
const response = await request(app).get('/api/images');
|
|
177
|
+
expect(response.body.route).toBe('images');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should mount tag routes at /api/tags', async () => {
|
|
181
|
+
const response = await request(app).get('/api/tags');
|
|
182
|
+
expect(response.body.route).toBe('tags');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('Global Error Handler', () => {
|
|
187
|
+
let originalEnv;
|
|
188
|
+
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
originalEnv = process.env.NODE_ENV;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
afterEach(() => {
|
|
194
|
+
process.env.NODE_ENV = originalEnv;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should return 500 for unhandled errors', async () => {
|
|
198
|
+
const testApp = express();
|
|
199
|
+
testApp.get('/error', (_req, _res, next) => {
|
|
200
|
+
next(new Error('Test error'));
|
|
201
|
+
});
|
|
202
|
+
testApp.use((err, req, res, _next) => {
|
|
203
|
+
const statusCode = err.statusCode || 500;
|
|
204
|
+
res.status(statusCode).json({ message: err.message });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const response = await request(testApp).get('/error');
|
|
208
|
+
expect(response.status).toBe(500);
|
|
209
|
+
expect(response.body.message).toBe('Test error');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should use custom statusCode if provided', async () => {
|
|
213
|
+
const testApp = express();
|
|
214
|
+
testApp.get('/error', (_req, _res, next) => {
|
|
215
|
+
const error = new Error('Not found');
|
|
216
|
+
error.statusCode = 404;
|
|
217
|
+
next(error);
|
|
218
|
+
});
|
|
219
|
+
testApp.use((err, req, res, _next) => {
|
|
220
|
+
const statusCode = err.statusCode || 500;
|
|
221
|
+
res.status(statusCode).json({ message: err.message });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const response = await request(testApp).get('/error');
|
|
225
|
+
expect(response.status).toBe(404);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should include stack trace in development mode', async () => {
|
|
229
|
+
process.env.NODE_ENV = 'development';
|
|
230
|
+
|
|
231
|
+
const testApp = express();
|
|
232
|
+
testApp.get('/error', (_req, _res, next) => {
|
|
233
|
+
next(new Error('Test error'));
|
|
234
|
+
});
|
|
235
|
+
testApp.use((err, req, res, _next) => {
|
|
236
|
+
res.status(500).json({
|
|
237
|
+
message: err.message,
|
|
238
|
+
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const response = await request(testApp).get('/error');
|
|
243
|
+
expect(response.body.stack).toBeDefined();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should not include stack trace in production mode', async () => {
|
|
247
|
+
process.env.NODE_ENV = 'production';
|
|
248
|
+
|
|
249
|
+
const testApp = express();
|
|
250
|
+
testApp.get('/error', (_req, _res, next) => {
|
|
251
|
+
next(new Error('Test error'));
|
|
252
|
+
});
|
|
253
|
+
testApp.use((err, req, res, _next) => {
|
|
254
|
+
res.status(500).json({
|
|
255
|
+
message: err.message,
|
|
256
|
+
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const response = await request(testApp).get('/error');
|
|
261
|
+
expect(response.body.stack).toBeUndefined();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should use response status from error.response.status', async () => {
|
|
265
|
+
const testApp = express();
|
|
266
|
+
testApp.get('/error', (_req, _res, next) => {
|
|
267
|
+
const error = new Error('Service unavailable');
|
|
268
|
+
error.response = { status: 503 };
|
|
269
|
+
next(error);
|
|
270
|
+
});
|
|
271
|
+
testApp.use((err, req, res, _next) => {
|
|
272
|
+
const statusCode = err.statusCode || err.response?.status || 500;
|
|
273
|
+
res.status(statusCode).json({ message: err.message });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const response = await request(testApp).get('/error');
|
|
277
|
+
expect(response.status).toBe(503);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('404 Handling', () => {
|
|
282
|
+
it('should return 404 for unknown routes', async () => {
|
|
283
|
+
const testApp = express();
|
|
284
|
+
testApp.get('/known', (req, res) => res.json({ ok: true }));
|
|
285
|
+
testApp.use((req, res) => {
|
|
286
|
+
res.status(404).json({ message: 'Not Found' });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const response = await request(testApp).get('/unknown-route');
|
|
290
|
+
expect(response.status).toBe(404);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('Server Startup', () => {
|
|
296
|
+
it('should export startServers functionality (indirect test)', async () => {
|
|
297
|
+
// Since index.js auto-starts servers on import, we test the concept indirectly
|
|
298
|
+
// by verifying the app structure works correctly
|
|
299
|
+
const app = express();
|
|
300
|
+
let serverStarted = false;
|
|
301
|
+
|
|
302
|
+
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
|
303
|
+
|
|
304
|
+
// Simulate startServers pattern
|
|
305
|
+
const mockStartServers = async () => {
|
|
306
|
+
serverStarted = true;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
await mockStartServers();
|
|
310
|
+
expect(serverStarted).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
});
|