@jgardner04/ghost-mcp-server 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -177,6 +177,90 @@ describe('Common Schemas', () => {
177
177
  it('should reject empty strings', () => {
178
178
  expect(() => htmlContentSchema.parse('')).toThrow();
179
179
  });
180
+
181
+ // XSS Prevention Tests
182
+ describe('XSS sanitization', () => {
183
+ it('should strip script tags', () => {
184
+ const result = htmlContentSchema.parse('<p>Safe</p><script>alert("xss")</script>');
185
+ expect(result).not.toContain('<script>');
186
+ expect(result).not.toContain('alert');
187
+ expect(result).toContain('<p>Safe</p>');
188
+ });
189
+
190
+ it('should strip onclick and other event handlers', () => {
191
+ const result = htmlContentSchema.parse('<p onclick="alert(1)">Click me</p>');
192
+ expect(result).not.toContain('onclick');
193
+ expect(result).toContain('<p>Click me</p>');
194
+ });
195
+
196
+ it('should strip javascript: URLs', () => {
197
+ const result = htmlContentSchema.parse('<a href="javascript:alert(1)">Link</a>');
198
+ expect(result).not.toContain('javascript:');
199
+ });
200
+
201
+ it('should strip onerror handlers on images', () => {
202
+ const result = htmlContentSchema.parse('<img src="x" onerror="alert(1)">');
203
+ expect(result).not.toContain('onerror');
204
+ });
205
+
206
+ it('should allow safe tags', () => {
207
+ const safeHtml =
208
+ '<h1>Title</h1><p>Paragraph</p><a href="https://example.com">Link</a><ul><li>Item</li></ul>';
209
+ const result = htmlContentSchema.parse(safeHtml);
210
+ expect(result).toContain('<h1>');
211
+ expect(result).toContain('<p>');
212
+ expect(result).toContain('<a ');
213
+ expect(result).toContain('<ul>');
214
+ expect(result).toContain('<li>');
215
+ });
216
+
217
+ it('should allow safe attributes on links', () => {
218
+ const result = htmlContentSchema.parse(
219
+ '<a href="https://example.com" title="Example">Link</a>'
220
+ );
221
+ expect(result).toContain('href="https://example.com"');
222
+ expect(result).toContain('title="Example"');
223
+ });
224
+
225
+ it('should allow safe attributes on images', () => {
226
+ const result = htmlContentSchema.parse(
227
+ '<img src="https://example.com/img.jpg" alt="Description" title="Title" width="100" height="100">'
228
+ );
229
+ expect(result).toContain('src="https://example.com/img.jpg"');
230
+ expect(result).toContain('alt="Description"');
231
+ });
232
+
233
+ it('should strip style attributes by default', () => {
234
+ const result = htmlContentSchema.parse('<p style="color: red">Styled</p>');
235
+ expect(result).not.toContain('style=');
236
+ });
237
+
238
+ it('should strip iframe tags', () => {
239
+ const result = htmlContentSchema.parse(
240
+ '<iframe src="https://evil.com"></iframe><p>Safe</p>'
241
+ );
242
+ expect(result).not.toContain('<iframe');
243
+ expect(result).toContain('<p>Safe</p>');
244
+ });
245
+
246
+ it('should strip data: URLs on images', () => {
247
+ // data: URLs can be used for XSS in some contexts
248
+ const result = htmlContentSchema.parse(
249
+ '<img src="data:text/html,<script>alert(1)</script>">'
250
+ );
251
+ // The src should either be removed or the tag stripped
252
+ expect(result).not.toContain('<script>');
253
+ });
254
+
255
+ it('should preserve text content while stripping dangerous elements', () => {
256
+ const result = htmlContentSchema.parse(
257
+ '<div>Safe text<script>evil()</script> more text</div>'
258
+ );
259
+ expect(result).toContain('Safe text');
260
+ expect(result).toContain('more text');
261
+ expect(result).not.toContain('evil');
262
+ });
263
+ });
180
264
  });
181
265
 
182
266
  describe('titleSchema', () => {
@@ -1,10 +1,54 @@
1
1
  import { z } from 'zod';
2
+ import sanitizeHtml from 'sanitize-html';
2
3
 
3
4
  /**
4
5
  * Common Zod schemas for validation across all Ghost MCP resources.
5
6
  * These validators provide consistent validation and security controls.
6
7
  */
7
8
 
9
+ /**
10
+ * HTML sanitization configuration
11
+ * Prevents XSS attacks by allowing only safe HTML tags and attributes
12
+ */
13
+ const htmlSanitizeConfig = {
14
+ allowedTags: [
15
+ 'h1',
16
+ 'h2',
17
+ 'h3',
18
+ 'h4',
19
+ 'h5',
20
+ 'h6',
21
+ 'blockquote',
22
+ 'p',
23
+ 'a',
24
+ 'ul',
25
+ 'ol',
26
+ 'nl',
27
+ 'li',
28
+ 'b',
29
+ 'i',
30
+ 'strong',
31
+ 'em',
32
+ 'strike',
33
+ 'code',
34
+ 'hr',
35
+ 'br',
36
+ 'div',
37
+ 'span',
38
+ 'img',
39
+ 'pre',
40
+ 'figure',
41
+ 'figcaption',
42
+ ],
43
+ allowedAttributes: {
44
+ a: ['href', 'name', 'target', 'rel', 'title'],
45
+ img: ['src', 'alt', 'title', 'width', 'height'],
46
+ '*': ['class', 'id'],
47
+ },
48
+ allowedSchemes: ['http', 'https', 'mailto'],
49
+ allowedSchemesAppliedToAttributes: ['href', 'src'],
50
+ };
51
+
8
52
  // ----- Basic Type Validators -----
9
53
 
10
54
  /**
@@ -106,10 +150,13 @@ export const visibilitySchema = z.enum(['public', 'members', 'paid', 'tiers'], {
106
150
 
107
151
  /**
108
152
  * HTML content validation schema
109
- * Validates that content is a non-empty string
110
- * Note: HTML sanitization should be performed separately
153
+ * Validates that content is a non-empty string and sanitizes HTML to prevent XSS
154
+ * Uses transform to sanitize HTML at schema level (defense-in-depth)
111
155
  */
112
- export const htmlContentSchema = z.string().min(1, 'HTML content cannot be empty');
156
+ export const htmlContentSchema = z
157
+ .string()
158
+ .min(1, 'HTML content cannot be empty')
159
+ .transform((html) => sanitizeHtml(html, htmlSanitizeConfig));
113
160
 
114
161
  /**
115
162
  * Title validation schema
@@ -128,24 +128,9 @@ describe('ghostServiceImproved - Members', () => {
128
128
  expect(result).toEqual(mockCreatedMember);
129
129
  });
130
130
 
131
- it('should throw validation error for missing email', async () => {
132
- await expect(createMember({})).rejects.toThrow('Member validation failed');
133
- });
134
-
135
- it('should throw validation error for invalid email', async () => {
136
- await expect(createMember({ email: 'invalid-email' })).rejects.toThrow(
137
- 'Member validation failed'
138
- );
139
- });
140
-
141
- it('should throw validation error for invalid labels type', async () => {
142
- await expect(
143
- createMember({
144
- email: 'test@example.com',
145
- labels: 'premium',
146
- })
147
- ).rejects.toThrow('Member validation failed');
148
- });
131
+ // NOTE: Input validation tests (missing email, invalid email, invalid labels)
132
+ // have been moved to MCP layer tests. The service layer now relies on
133
+ // Zod schema validation at the MCP tool layer.
149
134
 
150
135
  it('should handle Ghost API errors', async () => {
151
136
  const memberData = {
@@ -225,11 +210,8 @@ describe('ghostServiceImproved - Members', () => {
225
210
  );
226
211
  });
227
212
 
228
- it('should throw validation error for invalid email in update', async () => {
229
- await expect(updateMember('member-1', { email: 'invalid-email' })).rejects.toThrow(
230
- 'Member validation failed'
231
- );
232
- });
213
+ // NOTE: Input validation tests (invalid email in update) have been moved to
214
+ // MCP layer tests. The service layer now relies on Zod schema validation.
233
215
 
234
216
  it('should throw not found error if member does not exist', async () => {
235
217
  api.members.read.mockRejectedValue({
@@ -345,14 +327,8 @@ describe('ghostServiceImproved - Members', () => {
345
327
  );
346
328
  });
347
329
 
348
- it('should throw validation error for invalid limit', async () => {
349
- await expect(getMembers({ limit: 0 })).rejects.toThrow('Member query validation failed');
350
- await expect(getMembers({ limit: 101 })).rejects.toThrow('Member query validation failed');
351
- });
352
-
353
- it('should throw validation error for invalid page', async () => {
354
- await expect(getMembers({ page: 0 })).rejects.toThrow('Member query validation failed');
355
- });
330
+ // NOTE: Input validation tests (invalid limit, invalid page) have been moved to
331
+ // MCP layer tests. The service layer now relies on Zod schema validation.
356
332
 
357
333
  it('should return empty array when no members found', async () => {
358
334
  api.members.browse.mockResolvedValue([]);
@@ -407,15 +383,8 @@ describe('ghostServiceImproved - Members', () => {
407
383
  expect(result).toEqual(mockMember);
408
384
  });
409
385
 
410
- it('should throw validation error when neither id nor email provided', async () => {
411
- await expect(getMember({})).rejects.toThrow('Member lookup validation failed');
412
- });
413
-
414
- it('should throw validation error for invalid email format', async () => {
415
- await expect(getMember({ email: 'invalid-email' })).rejects.toThrow(
416
- 'Member lookup validation failed'
417
- );
418
- });
386
+ // NOTE: Input validation tests (missing id/email, invalid email format) have been
387
+ // moved to MCP layer tests. The service layer now relies on Zod schema validation.
419
388
 
420
389
  it('should throw not found error when member not found by ID', async () => {
421
390
  api.members.read.mockRejectedValue({
@@ -493,27 +462,9 @@ describe('ghostServiceImproved - Members', () => {
493
462
  );
494
463
  });
495
464
 
496
- it('should throw validation error for empty query', async () => {
497
- await expect(searchMembers('')).rejects.toThrow('Search query validation failed');
498
- await expect(searchMembers(' ')).rejects.toThrow('Search query validation failed');
499
- });
500
-
501
- it('should throw validation error for non-string query', async () => {
502
- await expect(searchMembers(123)).rejects.toThrow('Search query validation failed');
503
- await expect(searchMembers(null)).rejects.toThrow('Search query validation failed');
504
- });
505
-
506
- it('should throw validation error for invalid limit', async () => {
507
- await expect(searchMembers('test', { limit: 0 })).rejects.toThrow(
508
- 'Search options validation failed'
509
- );
510
- await expect(searchMembers('test', { limit: 51 })).rejects.toThrow(
511
- 'Search options validation failed'
512
- );
513
- await expect(searchMembers('test', { limit: 100 })).rejects.toThrow(
514
- 'Search options validation failed'
515
- );
516
- });
465
+ // NOTE: Input validation tests (empty query, non-string query, invalid limit)
466
+ // have been moved to MCP layer tests. The service layer now relies on
467
+ // Zod schema validation at the MCP tool layer.
517
468
 
518
469
  it('should sanitize query to prevent NQL injection', async () => {
519
470
  const mockMembers = [];
@@ -0,0 +1,392 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
3
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
4
+
5
+ // Mock the Ghost Admin API with tiers support
6
+ vi.mock('@tryghost/admin-api', () => ({
7
+ default: vi.fn(function () {
8
+ return {
9
+ posts: {
10
+ add: vi.fn(),
11
+ browse: vi.fn(),
12
+ read: vi.fn(),
13
+ edit: vi.fn(),
14
+ delete: vi.fn(),
15
+ },
16
+ pages: {
17
+ add: vi.fn(),
18
+ browse: vi.fn(),
19
+ read: vi.fn(),
20
+ edit: vi.fn(),
21
+ delete: vi.fn(),
22
+ },
23
+ tags: {
24
+ add: vi.fn(),
25
+ browse: vi.fn(),
26
+ read: vi.fn(),
27
+ edit: vi.fn(),
28
+ delete: vi.fn(),
29
+ },
30
+ members: {
31
+ add: vi.fn(),
32
+ browse: vi.fn(),
33
+ read: vi.fn(),
34
+ edit: vi.fn(),
35
+ delete: vi.fn(),
36
+ },
37
+ tiers: {
38
+ add: vi.fn(),
39
+ browse: vi.fn(),
40
+ read: vi.fn(),
41
+ edit: vi.fn(),
42
+ delete: vi.fn(),
43
+ },
44
+ site: {
45
+ read: vi.fn(),
46
+ },
47
+ images: {
48
+ upload: vi.fn(),
49
+ },
50
+ };
51
+ }),
52
+ }));
53
+
54
+ // Mock dotenv
55
+ vi.mock('dotenv', () => mockDotenv());
56
+
57
+ // Mock logger
58
+ vi.mock('../../utils/logger.js', () => ({
59
+ createContextLogger: createMockContextLogger(),
60
+ }));
61
+
62
+ // Mock fs for validateImagePath
63
+ vi.mock('fs/promises', () => ({
64
+ default: {
65
+ access: vi.fn(),
66
+ },
67
+ }));
68
+
69
+ // Import after setting up mocks
70
+ import {
71
+ createTier,
72
+ updateTier,
73
+ deleteTier,
74
+ getTiers,
75
+ getTier,
76
+ api,
77
+ } from '../ghostServiceImproved.js';
78
+
79
+ describe('ghostServiceImproved - Tiers', () => {
80
+ beforeEach(() => {
81
+ // Reset all mocks before each test
82
+ vi.clearAllMocks();
83
+ });
84
+
85
+ describe('createTier', () => {
86
+ it('should create a tier with required fields', async () => {
87
+ const tierData = {
88
+ name: 'Premium',
89
+ currency: 'USD',
90
+ };
91
+
92
+ const mockCreatedTier = {
93
+ id: 'tier-1',
94
+ name: 'Premium',
95
+ currency: 'USD',
96
+ type: 'paid',
97
+ active: true,
98
+ };
99
+
100
+ api.tiers.add.mockResolvedValue(mockCreatedTier);
101
+
102
+ const result = await createTier(tierData);
103
+
104
+ expect(api.tiers.add).toHaveBeenCalledWith(
105
+ expect.objectContaining({
106
+ name: 'Premium',
107
+ currency: 'USD',
108
+ }),
109
+ expect.any(Object)
110
+ );
111
+ expect(result).toEqual(mockCreatedTier);
112
+ });
113
+
114
+ it('should create a tier with all optional fields', async () => {
115
+ const tierData = {
116
+ name: 'Premium Membership',
117
+ currency: 'USD',
118
+ description: 'Access to premium content',
119
+ monthly_price: 999,
120
+ yearly_price: 9999,
121
+ benefits: ['Ad-free experience', 'Exclusive content'],
122
+ welcome_page_url: 'https://example.com/welcome',
123
+ };
124
+
125
+ const mockCreatedTier = {
126
+ id: 'tier-2',
127
+ ...tierData,
128
+ type: 'paid',
129
+ active: true,
130
+ };
131
+
132
+ api.tiers.add.mockResolvedValue(mockCreatedTier);
133
+
134
+ const result = await createTier(tierData);
135
+
136
+ expect(api.tiers.add).toHaveBeenCalledWith(
137
+ expect.objectContaining(tierData),
138
+ expect.any(Object)
139
+ );
140
+ expect(result).toEqual(mockCreatedTier);
141
+ });
142
+
143
+ it('should throw ValidationError when name is missing', async () => {
144
+ await expect(
145
+ createTier({
146
+ currency: 'USD',
147
+ })
148
+ ).rejects.toThrow('Tier validation failed');
149
+ });
150
+
151
+ it('should throw ValidationError when currency is missing', async () => {
152
+ await expect(
153
+ createTier({
154
+ name: 'Premium',
155
+ })
156
+ ).rejects.toThrow('Tier validation failed');
157
+ });
158
+
159
+ it('should throw ValidationError when currency is invalid', async () => {
160
+ await expect(
161
+ createTier({
162
+ name: 'Premium',
163
+ currency: 'us',
164
+ })
165
+ ).rejects.toThrow('Tier validation failed');
166
+ });
167
+ });
168
+
169
+ describe('getTiers', () => {
170
+ it('should get all tiers with default options', async () => {
171
+ const mockTiers = [
172
+ {
173
+ id: 'tier-1',
174
+ name: 'Free',
175
+ type: 'free',
176
+ active: true,
177
+ },
178
+ {
179
+ id: 'tier-2',
180
+ name: 'Premium',
181
+ type: 'paid',
182
+ active: true,
183
+ },
184
+ ];
185
+
186
+ api.tiers.browse.mockResolvedValue(mockTiers);
187
+
188
+ const result = await getTiers();
189
+
190
+ expect(api.tiers.browse).toHaveBeenCalledWith(
191
+ expect.objectContaining({
192
+ limit: 15,
193
+ }),
194
+ expect.any(Object)
195
+ );
196
+ expect(result).toEqual(mockTiers);
197
+ });
198
+
199
+ it('should get tiers with custom limit', async () => {
200
+ const mockTiers = [
201
+ {
202
+ id: 'tier-1',
203
+ name: 'Free',
204
+ type: 'free',
205
+ active: true,
206
+ },
207
+ ];
208
+
209
+ api.tiers.browse.mockResolvedValue(mockTiers);
210
+
211
+ const result = await getTiers({ limit: 5 });
212
+
213
+ expect(api.tiers.browse).toHaveBeenCalledWith(
214
+ expect.objectContaining({
215
+ limit: 5,
216
+ }),
217
+ expect.any(Object)
218
+ );
219
+ expect(result).toEqual(mockTiers);
220
+ });
221
+
222
+ it('should get tiers with filter', async () => {
223
+ const mockTiers = [
224
+ {
225
+ id: 'tier-2',
226
+ name: 'Premium',
227
+ type: 'paid',
228
+ active: true,
229
+ },
230
+ ];
231
+
232
+ api.tiers.browse.mockResolvedValue(mockTiers);
233
+
234
+ const result = await getTiers({ filter: 'type:paid' });
235
+
236
+ expect(api.tiers.browse).toHaveBeenCalledWith(
237
+ expect.objectContaining({
238
+ filter: 'type:paid',
239
+ }),
240
+ expect.any(Object)
241
+ );
242
+ expect(result).toEqual(mockTiers);
243
+ });
244
+
245
+ it('should return empty array when no tiers found', async () => {
246
+ api.tiers.browse.mockResolvedValue([]);
247
+
248
+ const result = await getTiers();
249
+
250
+ expect(result).toEqual([]);
251
+ });
252
+
253
+ it('should throw ValidationError for invalid limit', async () => {
254
+ await expect(getTiers({ limit: 0 })).rejects.toThrow('Tier query validation failed');
255
+ await expect(getTiers({ limit: 101 })).rejects.toThrow('Tier query validation failed');
256
+ });
257
+ });
258
+
259
+ describe('getTier', () => {
260
+ it('should get a single tier by ID', async () => {
261
+ const mockTier = {
262
+ id: 'tier-1',
263
+ name: 'Premium',
264
+ currency: 'USD',
265
+ type: 'paid',
266
+ active: true,
267
+ };
268
+
269
+ api.tiers.read.mockResolvedValue(mockTier);
270
+
271
+ const result = await getTier('tier-1');
272
+
273
+ expect(api.tiers.read).toHaveBeenCalledWith(
274
+ expect.objectContaining({
275
+ id: 'tier-1',
276
+ }),
277
+ expect.objectContaining({
278
+ id: 'tier-1',
279
+ })
280
+ );
281
+ expect(result).toEqual(mockTier);
282
+ });
283
+
284
+ it('should throw ValidationError when ID is missing', async () => {
285
+ await expect(getTier()).rejects.toThrow('Tier ID is required');
286
+ });
287
+
288
+ it('should throw ValidationError when ID is empty string', async () => {
289
+ await expect(getTier('')).rejects.toThrow('Tier ID is required');
290
+ });
291
+
292
+ it('should throw NotFoundError when tier does not exist', async () => {
293
+ const mockError = new Error('Tier not found');
294
+ mockError.response = { status: 404 };
295
+
296
+ api.tiers.read.mockRejectedValue(mockError);
297
+
298
+ await expect(getTier('nonexistent-id')).rejects.toThrow();
299
+ });
300
+ });
301
+
302
+ describe('updateTier', () => {
303
+ it('should update a tier', async () => {
304
+ const existingTier = {
305
+ id: 'tier-1',
306
+ name: 'Premium',
307
+ currency: 'USD',
308
+ monthly_price: 999,
309
+ updated_at: '2024-01-01T00:00:00.000Z',
310
+ };
311
+
312
+ const updateData = {
313
+ name: 'Premium Plus',
314
+ monthly_price: 1299,
315
+ };
316
+
317
+ const mockUpdatedTier = {
318
+ ...existingTier,
319
+ ...updateData,
320
+ };
321
+
322
+ api.tiers.read.mockResolvedValue(existingTier);
323
+ api.tiers.edit.mockResolvedValue(mockUpdatedTier);
324
+
325
+ const result = await updateTier('tier-1', updateData);
326
+
327
+ expect(api.tiers.read).toHaveBeenCalledWith(
328
+ expect.objectContaining({ id: 'tier-1' }),
329
+ expect.objectContaining({ id: 'tier-1' })
330
+ );
331
+ expect(api.tiers.edit).toHaveBeenCalledWith(
332
+ expect.objectContaining({
333
+ ...existingTier,
334
+ ...updateData,
335
+ }),
336
+ expect.objectContaining({
337
+ id: 'tier-1',
338
+ })
339
+ );
340
+ expect(result).toEqual(mockUpdatedTier);
341
+ });
342
+
343
+ it('should throw ValidationError when ID is missing', async () => {
344
+ await expect(updateTier('', { name: 'Updated' })).rejects.toThrow(
345
+ 'Tier ID is required for update'
346
+ );
347
+ });
348
+
349
+ it('should throw ValidationError for invalid update data', async () => {
350
+ await expect(updateTier('tier-1', { monthly_price: -100 })).rejects.toThrow(
351
+ 'Tier validation failed'
352
+ );
353
+ });
354
+
355
+ it('should throw NotFoundError when tier does not exist', async () => {
356
+ const mockError = new Error('Tier not found');
357
+ mockError.response = { status: 404 };
358
+
359
+ api.tiers.read.mockRejectedValue(mockError);
360
+
361
+ await expect(updateTier('nonexistent-id', { name: 'Updated' })).rejects.toThrow();
362
+ });
363
+ });
364
+
365
+ describe('deleteTier', () => {
366
+ it('should delete a tier', async () => {
367
+ api.tiers.delete.mockResolvedValue({ success: true });
368
+
369
+ const result = await deleteTier('tier-1');
370
+
371
+ expect(api.tiers.delete).toHaveBeenCalledWith('tier-1', expect.any(Object));
372
+ expect(result).toEqual({ success: true });
373
+ });
374
+
375
+ it('should throw ValidationError when ID is missing', async () => {
376
+ await expect(deleteTier()).rejects.toThrow('Tier ID is required for deletion');
377
+ });
378
+
379
+ it('should throw ValidationError when ID is empty string', async () => {
380
+ await expect(deleteTier('')).rejects.toThrow('Tier ID is required for deletion');
381
+ });
382
+
383
+ it('should throw NotFoundError when tier does not exist', async () => {
384
+ const mockError = new Error('Tier not found');
385
+ mockError.response = { status: 404 };
386
+
387
+ api.tiers.delete.mockRejectedValue(mockError);
388
+
389
+ await expect(deleteTier('nonexistent-id')).rejects.toThrow();
390
+ });
391
+ });
392
+ });