@jgardner04/ghost-mcp-server 1.13.0 → 1.13.2

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.
@@ -172,6 +172,106 @@ describe('Tag Schemas', () => {
172
172
  expect(result).toBeDefined();
173
173
  // Note: optional fields with defaults don't apply when field is omitted
174
174
  });
175
+
176
+ it('should accept limit as "all" string', () => {
177
+ const query = {
178
+ limit: 'all',
179
+ };
180
+
181
+ const result = tagQuerySchema.parse(query);
182
+ expect(result.limit).toBe('all');
183
+ });
184
+
185
+ it('should accept limit as number within range', () => {
186
+ const query = {
187
+ limit: 50,
188
+ };
189
+
190
+ const result = tagQuerySchema.parse(query);
191
+ expect(result.limit).toBe(50);
192
+ });
193
+
194
+ it('should reject limit as invalid string', () => {
195
+ const query = {
196
+ limit: 'invalid',
197
+ };
198
+
199
+ expect(() => tagQuerySchema.parse(query)).toThrow();
200
+ });
201
+
202
+ it('should reject limit greater than 100', () => {
203
+ const query = {
204
+ limit: 101,
205
+ };
206
+
207
+ expect(() => tagQuerySchema.parse(query)).toThrow();
208
+ });
209
+
210
+ it('should reject limit less than 1', () => {
211
+ const query = {
212
+ limit: 0,
213
+ };
214
+
215
+ expect(() => tagQuerySchema.parse(query)).toThrow();
216
+ });
217
+
218
+ it('should accept limit as string number and transform to number', () => {
219
+ const query = {
220
+ limit: '50',
221
+ };
222
+
223
+ const result = tagQuerySchema.parse(query);
224
+ expect(result.limit).toBe(50);
225
+ expect(typeof result.limit).toBe('number');
226
+ });
227
+
228
+ it('should accept page as string number and transform to number', () => {
229
+ const query = {
230
+ page: '3',
231
+ };
232
+
233
+ const result = tagQuerySchema.parse(query);
234
+ expect(result.page).toBe(3);
235
+ expect(typeof result.page).toBe('number');
236
+ });
237
+
238
+ it('should reject query with both name and filter parameters', () => {
239
+ const query = {
240
+ name: 'Technology',
241
+ filter: 'visibility:public',
242
+ };
243
+
244
+ expect(() => tagQuerySchema.parse(query)).toThrow();
245
+ try {
246
+ tagQuerySchema.parse(query);
247
+ } catch (error) {
248
+ expect(error.errors[0].message).toContain('Cannot specify both "name" and "filter"');
249
+ }
250
+ });
251
+
252
+ it('should reject name with invalid characters', () => {
253
+ const query = {
254
+ name: 'test;DROP',
255
+ };
256
+
257
+ expect(() => tagQuerySchema.parse(query)).toThrow(/invalid characters/);
258
+ });
259
+
260
+ it('should accept name with apostrophe', () => {
261
+ const query = {
262
+ name: "O'Reilly",
263
+ };
264
+
265
+ expect(() => tagQuerySchema.parse(query)).not.toThrow();
266
+ });
267
+
268
+ it('should accept name with spaces, hyphens, and underscores', () => {
269
+ const query = {
270
+ name: 'Tech News 2024-Web_Dev',
271
+ };
272
+
273
+ expect(() => tagQuerySchema.parse(query)).not.toThrow();
274
+ });
175
275
  });
176
276
 
177
277
  describe('tagIdSchema', () => {
@@ -62,8 +62,6 @@ export const createPageSchema = z.object({
62
62
  tags: tagsSchema.describe('Array of tag names or IDs (rarely used for pages)'),
63
63
  authors: authorsSchema.describe('Array of author IDs or emails'),
64
64
  published_at: isoDateSchema.optional().describe('Scheduled publish time (ISO 8601 format)'),
65
- updated_at: isoDateSchema.optional(),
66
- created_at: isoDateSchema.optional(),
67
65
  codeinjection_head: z.string().optional(),
68
66
  codeinjection_foot: z.string().optional(),
69
67
  custom_template: z.string().optional().describe('Custom template filename'),
@@ -57,11 +57,13 @@ export const createPostSchema = z.object({
57
57
  .max(500, 'Twitter description cannot exceed 500 characters')
58
58
  .optional(),
59
59
  canonical_url: canonicalUrlSchema,
60
- tags: tagsSchema.describe('Array of tag names or IDs to associate with the post'),
61
- authors: authorsSchema.describe('Array of author IDs or emails'),
60
+ tags: tagsSchema.describe(
61
+ 'Array of tag names or IDs to associate with the post. On update, this fully replaces the existing tags array (not merged).'
62
+ ),
63
+ authors: authorsSchema.describe(
64
+ 'Array of author IDs or emails. On update, this fully replaces the existing authors array (not merged).'
65
+ ),
62
66
  published_at: isoDateSchema.optional().describe('Scheduled publish time (ISO 8601 format)'),
63
- updated_at: isoDateSchema.optional(),
64
- created_at: isoDateSchema.optional(),
65
67
  codeinjection_head: z.string().optional(),
66
68
  codeinjection_foot: z.string().optional(),
67
69
  custom_template: z.string().optional().describe('Custom template filename'),
@@ -57,14 +57,33 @@ export const createTagSchema = z.object({
57
57
  export const updateTagSchema = createTagSchema.partial();
58
58
 
59
59
  /**
60
- * Schema for tag query/filter parameters
60
+ * Base schema for tag query/filter parameters (without refinement)
61
+ * Exported for use in MCP server where .partial() is needed
61
62
  */
62
- export const tagQuerySchema = z.object({
63
- name: z.string().optional().describe('Filter by exact tag name'),
63
+ export const tagQueryBaseSchema = z.object({
64
+ name: z
65
+ .string()
66
+ .regex(
67
+ /^[a-zA-Z0-9\s\-_']+$/,
68
+ 'Tag name contains invalid characters. Only letters, numbers, spaces, hyphens, underscores, and apostrophes are allowed'
69
+ )
70
+ .optional()
71
+ .describe('Filter by exact tag name (legacy parameter, converted to filter internally)'),
64
72
  slug: z.string().optional().describe('Filter by tag slug'),
65
73
  visibility: visibilitySchema.optional().describe('Filter by visibility'),
66
- limit: z.number().int().min(1).max(100).default(15).optional(),
67
- page: z.number().int().min(1).default(1).optional(),
74
+ limit: z
75
+ .union([
76
+ z.number().int().min(1).max(100),
77
+ z.string().regex(/^\d+$/).transform(Number),
78
+ z.literal('all'),
79
+ ])
80
+ .default(15)
81
+ .optional()
82
+ .describe('Number of tags to return (1-100) or "all" for all tags'),
83
+ page: z
84
+ .union([z.number().int().min(1), z.string().regex(/^\d+$/).transform(Number)])
85
+ .default(1)
86
+ .optional(),
68
87
  filter: z
69
88
  .string()
70
89
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
@@ -77,6 +96,15 @@ export const tagQuerySchema = z.object({
77
96
  order: z.string().optional().describe('Order results (e.g., "name ASC", "created_at DESC")'),
78
97
  });
79
98
 
99
+ /**
100
+ * Schema for tag query/filter parameters with validation
101
+ * Note: Only one of 'name' or 'filter' should be provided, not both
102
+ */
103
+ export const tagQuerySchema = tagQueryBaseSchema.refine((data) => !(data.name && data.filter), {
104
+ message: 'Cannot specify both "name" and "filter" parameters. Use "filter" for advanced queries.',
105
+ path: ['filter'],
106
+ });
107
+
80
108
  /**
81
109
  * Schema for tag ID parameter
82
110
  */
@@ -276,24 +276,7 @@ describe('ghostService', () => {
276
276
  });
277
277
 
278
278
  describe('getTags', () => {
279
- it('should reject tag names with invalid characters', async () => {
280
- await expect(getTags("'; DROP TABLE tags; --")).rejects.toThrow(
281
- 'Tag name contains invalid characters'
282
- );
283
- });
284
-
285
- it('should accept valid tag names', async () => {
286
- const validNames = ['Test Tag', 'test-tag', 'test_tag', 'Tag123'];
287
- const expectedTags = [{ id: '1', name: 'Tag' }];
288
- api.tags.browse.mockResolvedValue(expectedTags);
289
-
290
- for (const name of validNames) {
291
- const result = await getTags(name);
292
- expect(result).toEqual(expectedTags);
293
- }
294
- });
295
-
296
- it('should handle tags without filter when name is not provided', async () => {
279
+ it('should get all tags when no options provided', async () => {
297
280
  const expectedTags = [
298
281
  { id: '1', name: 'Tag1' },
299
282
  { id: '2', name: 'Tag2' },
@@ -303,17 +286,41 @@ describe('ghostService', () => {
303
286
  const result = await getTags();
304
287
 
305
288
  expect(result).toEqual(expectedTags);
306
- expect(api.tags.browse).toHaveBeenCalledWith({ limit: 'all' }, {});
289
+ expect(api.tags.browse).toHaveBeenCalledWith({ limit: 15 }, {});
307
290
  });
308
291
 
309
- it('should properly escape tag names in filter', async () => {
310
- const expectedTags = [{ id: '1', name: "Tag's Name" }];
292
+ it('should pass options to browse endpoint', async () => {
293
+ const expectedTags = [{ id: '1', name: 'Tag' }];
311
294
  api.tags.browse.mockResolvedValue(expectedTags);
312
295
 
313
- // This should work because we properly escape single quotes
314
- const result = await getTags('Valid Tag');
296
+ const result = await getTags({ limit: 5, filter: "name:'Test'" });
315
297
 
316
298
  expect(result).toEqual(expectedTags);
299
+ expect(api.tags.browse).toHaveBeenCalledWith({ limit: 5, filter: "name:'Test'" }, {});
300
+ });
301
+
302
+ it('should return empty array on null response', async () => {
303
+ api.tags.browse.mockResolvedValue(null);
304
+
305
+ const result = await getTags();
306
+
307
+ expect(result).toEqual([]);
308
+ });
309
+
310
+ it('should handle errors and rethrow', async () => {
311
+ const error = new Error('API error');
312
+ api.tags.browse.mockRejectedValue(error);
313
+
314
+ await expect(getTags()).rejects.toThrow('API error');
315
+ });
316
+
317
+ it('should merge default limit with custom options', async () => {
318
+ const expectedTags = [{ id: '1', name: 'Tag' }];
319
+ api.tags.browse.mockResolvedValue(expectedTags);
320
+
321
+ await getTags({ filter: "name:'Custom'" });
322
+
323
+ expect(api.tags.browse).toHaveBeenCalledWith({ limit: 15, filter: "name:'Custom'" }, {});
317
324
  });
318
325
  });
319
326
  });
@@ -144,7 +144,7 @@ describe('ghostServiceImproved - Members', () => {
144
144
  });
145
145
 
146
146
  describe('updateMember', () => {
147
- it('should update a member with valid ID and data', async () => {
147
+ it('should send only update fields and updated_at, not the full existing member', async () => {
148
148
  const memberId = 'member-1';
149
149
  const updateData = {
150
150
  name: 'Jane Doe',
@@ -153,8 +153,10 @@ describe('ghostServiceImproved - Members', () => {
153
153
 
154
154
  const mockExistingMember = {
155
155
  id: memberId,
156
+ uuid: 'abc-def-123',
156
157
  email: 'test@example.com',
157
158
  name: 'John Doe',
159
+ status: 'free',
158
160
  updated_at: '2023-01-01T00:00:00.000Z',
159
161
  };
160
162
 
@@ -169,13 +171,15 @@ describe('ghostServiceImproved - Members', () => {
169
171
  const result = await updateMember(memberId, updateData);
170
172
 
171
173
  expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: memberId });
174
+ // Should send ONLY updateData + updated_at, NOT the full existing member
172
175
  expect(api.members.edit).toHaveBeenCalledWith(
173
- expect.objectContaining({
174
- ...mockExistingMember,
175
- ...updateData,
176
- }),
176
+ { name: 'Jane Doe', note: 'Updated note', updated_at: '2023-01-01T00:00:00.000Z' },
177
177
  expect.objectContaining({ id: memberId })
178
178
  );
179
+ // Verify read-only fields are NOT sent
180
+ const editCallData = api.members.edit.mock.calls[0][0];
181
+ expect(editCallData).not.toHaveProperty('uuid');
182
+ expect(editCallData).not.toHaveProperty('status');
179
183
  expect(result).toEqual(mockUpdatedMember);
180
184
  });
181
185
 
@@ -194,10 +194,12 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
194
194
  });
195
195
 
196
196
  describe('updateNewsletter', () => {
197
- it('should update a newsletter successfully', async () => {
197
+ it('should send only update fields and updated_at, not the full existing newsletter', async () => {
198
198
  const existingNewsletter = {
199
199
  id: 'newsletter-123',
200
+ uuid: 'abc-def-123',
200
201
  name: 'Old Name',
202
+ slug: 'old-name',
201
203
  updated_at: '2024-01-01T00:00:00.000Z',
202
204
  };
203
205
  const updateData = { name: 'New Name' };
@@ -210,13 +212,15 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
210
212
 
211
213
  expect(result).toEqual(updatedNewsletter);
212
214
  expect(mockNewslettersApi.read).toHaveBeenCalledWith({}, { id: 'newsletter-123' });
215
+ // Should send ONLY updateData + updated_at, NOT the full existing newsletter
213
216
  expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
214
- {
215
- ...existingNewsletter,
216
- ...updateData,
217
- },
217
+ { name: 'New Name', updated_at: '2024-01-01T00:00:00.000Z' },
218
218
  { id: 'newsletter-123' }
219
219
  );
220
+ // Verify read-only fields are NOT sent
221
+ const editCallData = mockNewslettersApi.edit.mock.calls[0][0];
222
+ expect(editCallData).not.toHaveProperty('uuid');
223
+ expect(editCallData).not.toHaveProperty('slug');
220
224
  });
221
225
 
222
226
  it('should update newsletter with email settings', async () => {
@@ -236,9 +240,11 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
236
240
 
237
241
  await updateNewsletter('newsletter-123', updateData);
238
242
 
239
- expect(mockNewslettersApi.edit).toHaveBeenCalledWith(expect.objectContaining(updateData), {
240
- id: 'newsletter-123',
241
- });
243
+ // Should send ONLY updateData + updated_at
244
+ expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
245
+ { ...updateData, updated_at: '2024-01-01T00:00:00.000Z' },
246
+ { id: 'newsletter-123' }
247
+ );
242
248
  });
243
249
 
244
250
  it('should throw ValidationError if ID is missing', async () => {
@@ -270,10 +276,9 @@ describe('ghostServiceImproved - Newsletter Operations', () => {
270
276
 
271
277
  await updateNewsletter('newsletter-123', updateData);
272
278
 
279
+ // Should send ONLY updateData + updated_at
273
280
  expect(mockNewslettersApi.edit).toHaveBeenCalledWith(
274
- expect.objectContaining({
275
- updated_at: '2024-01-01T00:00:00.000Z',
276
- }),
281
+ { description: 'Updated description', updated_at: '2024-01-01T00:00:00.000Z' },
277
282
  { id: 'newsletter-123' }
278
283
  );
279
284
  });
@@ -63,6 +63,7 @@ import {
63
63
  api,
64
64
  validators,
65
65
  } from '../ghostServiceImproved.js';
66
+ import { createPageSchema, updatePageSchema } from '../../schemas/pageSchemas.js';
66
67
 
67
68
  describe('ghostServiceImproved - Pages', () => {
68
69
  beforeEach(() => {
@@ -219,7 +220,7 @@ describe('ghostServiceImproved - Pages', () => {
219
220
  expect(api.pages.add).toHaveBeenCalledWith(pageData, { source: 'html' });
220
221
  });
221
222
 
222
- it('should sanitize HTML content', async () => {
223
+ it('should pass HTML through without service-layer sanitization (schema layer handles it)', async () => {
223
224
  const pageData = {
224
225
  title: 'Test Page',
225
226
  html: '<p>Safe content</p><script>alert("xss")</script>',
@@ -228,10 +229,10 @@ describe('ghostServiceImproved - Pages', () => {
228
229
 
229
230
  await createPage(pageData);
230
231
 
231
- // Verify that the HTML was sanitized (script tags removed)
232
+ // HTML sanitization is enforced at the schema layer via htmlContentSchema,
233
+ // not at the service layer
232
234
  const calledWith = api.pages.add.mock.calls[0][0];
233
- expect(calledWith.html).not.toContain('<script>');
234
- expect(calledWith.html).toContain('<p>Safe content</p>');
235
+ expect(calledWith.html).toBeDefined();
235
236
  });
236
237
 
237
238
  it('should handle Ghost API validation errors (422)', async () => {
@@ -266,12 +267,16 @@ describe('ghostServiceImproved - Pages', () => {
266
267
  await expect(updatePage('', { title: 'Updated' })).rejects.toThrow('Page ID is required');
267
268
  });
268
269
 
269
- it('should update page with valid data', async () => {
270
+ it('should send only update fields and updated_at, not the full existing page', async () => {
270
271
  const pageId = 'page-123';
271
272
  const existingPage = {
272
273
  id: pageId,
274
+ uuid: 'abc-def-123',
273
275
  title: 'Original Title',
276
+ slug: 'original-title',
274
277
  html: '<p>Original content</p>',
278
+ url: 'https://example.com/original-title',
279
+ reading_time: 2,
275
280
  updated_at: '2024-01-01T00:00:00.000Z',
276
281
  };
277
282
  const updateData = { title: 'Updated Title' };
@@ -285,10 +290,16 @@ describe('ghostServiceImproved - Pages', () => {
285
290
  expect(result).toEqual(expectedPage);
286
291
  // handleApiRequest calls read with (options, data), where options={} and data={id}
287
292
  expect(api.pages.read).toHaveBeenCalledWith({}, { id: pageId });
293
+ // Should send ONLY updateData + updated_at, NOT the full existing page
288
294
  expect(api.pages.edit).toHaveBeenCalledWith(
289
- { ...existingPage, ...updateData },
295
+ { title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
290
296
  { id: pageId }
291
297
  );
298
+ // Verify read-only fields are NOT sent
299
+ const editCallData = api.pages.edit.mock.calls[0][0];
300
+ expect(editCallData).not.toHaveProperty('uuid');
301
+ expect(editCallData).not.toHaveProperty('url');
302
+ expect(editCallData).not.toHaveProperty('reading_time');
292
303
  });
293
304
 
294
305
  it('should handle page not found (404)', async () => {
@@ -317,16 +328,18 @@ describe('ghostServiceImproved - Pages', () => {
317
328
  expect(editCall.updated_at).toBe('2024-01-01T00:00:00.000Z');
318
329
  });
319
330
 
320
- it('should sanitize HTML content in updates', async () => {
331
+ it('should throw ValidationError when updating to scheduled without published_at', async () => {
321
332
  const pageId = 'page-123';
322
- const existingPage = { id: pageId, title: 'Test', updated_at: '2024-01-01T00:00:00.000Z' };
333
+ const existingPage = {
334
+ id: pageId,
335
+ title: 'Test Page',
336
+ updated_at: '2024-01-01T00:00:00.000Z',
337
+ };
323
338
  api.pages.read.mockResolvedValue(existingPage);
324
- api.pages.edit.mockResolvedValue({ ...existingPage });
325
339
 
326
- await updatePage(pageId, { html: '<p>Safe</p><script>alert("xss")</script>' });
327
-
328
- const editCall = api.pages.edit.mock.calls[0][0];
329
- expect(editCall.html).not.toContain('<script>');
340
+ await expect(updatePage(pageId, { status: 'scheduled' })).rejects.toThrow(
341
+ 'Page validation failed'
342
+ );
330
343
  });
331
344
  });
332
345
 
@@ -558,4 +571,34 @@ describe('ghostServiceImproved - Pages', () => {
558
571
  expect(browseCall.filter).toContain('+');
559
572
  });
560
573
  });
574
+
575
+ describe('HTML sanitization (schema + service integration)', () => {
576
+ it('should strip XSS from page HTML when input flows through schema validation (production path)', async () => {
577
+ const rawInput = { title: 'Test Page', html: '<p>Safe</p><script>alert("xss")</script>' };
578
+ const validated = createPageSchema.parse(rawInput);
579
+
580
+ api.pages.add.mockResolvedValue({ id: '1', ...validated });
581
+ await createPage(validated);
582
+
583
+ const sentToApi = api.pages.add.mock.calls[0][0];
584
+ expect(sentToApi.html).not.toContain('<script>');
585
+ expect(sentToApi.html).not.toContain('alert');
586
+ expect(sentToApi.html).toContain('<p>Safe</p>');
587
+ });
588
+
589
+ it('should strip XSS from page HTML on update when input flows through schema validation', async () => {
590
+ const rawUpdate = { html: '<p>Updated</p><img src=x onerror="alert(1)">' };
591
+ const validated = updatePageSchema.parse(rawUpdate);
592
+
593
+ const existingPage = { id: 'page-1', updated_at: '2024-01-01T00:00:00.000Z' };
594
+ api.pages.read.mockResolvedValue(existingPage);
595
+ api.pages.edit.mockResolvedValue({ ...existingPage, ...validated });
596
+
597
+ await updatePage('page-1', validated);
598
+
599
+ const sentToApi = api.pages.edit.mock.calls[0][0];
600
+ expect(sentToApi.html).not.toContain('onerror');
601
+ expect(sentToApi.html).toContain('<img src="x"');
602
+ });
603
+ });
561
604
  });