@jgardner04/ghost-mcp-server 1.13.1 → 1.13.3

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.
Files changed (35) hide show
  1. package/package.json +6 -18
  2. package/src/__tests__/mcp_server.test.js +204 -117
  3. package/src/__tests__/mcp_server_pages.test.js +32 -18
  4. package/src/config/mcp-config.js +1 -1
  5. package/src/controllers/__tests__/tagController.test.js +12 -8
  6. package/src/controllers/tagController.js +2 -2
  7. package/src/errors/__tests__/index.test.js +3 -3
  8. package/src/errors/index.js +1 -1
  9. package/src/index.js +1 -1
  10. package/src/mcp_server.js +37 -33
  11. package/src/schemas/__tests__/postSchemas.test.js +19 -0
  12. package/src/schemas/__tests__/tagSchemas.test.js +1 -1
  13. package/src/schemas/common.js +2 -2
  14. package/src/schemas/memberSchemas.js +20 -8
  15. package/src/schemas/newsletterSchemas.js +10 -10
  16. package/src/schemas/pageSchemas.js +16 -13
  17. package/src/schemas/postSchemas.js +22 -13
  18. package/src/schemas/tagSchemas.js +12 -7
  19. package/src/schemas/tierSchemas.js +17 -8
  20. package/src/services/__tests__/ghostServiceImproved.members.test.js +15 -6
  21. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +21 -12
  22. package/src/services/__tests__/ghostServiceImproved.pages.test.js +59 -16
  23. package/src/services/__tests__/ghostServiceImproved.posts.test.js +233 -0
  24. package/src/services/__tests__/ghostServiceImproved.tags.test.js +13 -2
  25. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +18 -19
  26. package/src/services/__tests__/memberService.test.js +0 -28
  27. package/src/services/__tests__/tierService.test.js +0 -28
  28. package/src/services/ghostServiceImproved.js +108 -379
  29. package/src/services/imageProcessingService.js +1 -1
  30. package/src/services/memberService.js +0 -13
  31. package/src/services/tierService.js +0 -13
  32. package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
  33. package/src/utils/__tests__/urlValidator.test.js +137 -1
  34. package/src/utils/nqlSanitizer.js +11 -0
  35. package/src/utils/urlValidator.js +25 -2
@@ -35,7 +35,7 @@ import {
35
35
  */
36
36
  export const createPostSchema = z.object({
37
37
  title: titleSchema,
38
- html: htmlContentSchema.describe('HTML content of the post'),
38
+ html: htmlContentSchema.meta({ description: 'HTML content of the post' }),
39
39
  slug: slugSchema.optional(),
40
40
  status: postStatusSchema.default('draft'),
41
41
  visibility: visibilitySchema.default('public'),
@@ -57,15 +57,21 @@ 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'),
62
- published_at: isoDateSchema.optional().describe('Scheduled publish time (ISO 8601 format)'),
63
- updated_at: isoDateSchema.optional(),
64
- created_at: isoDateSchema.optional(),
60
+ tags: tagsSchema.meta({
61
+ description:
62
+ 'Array of tag names or IDs to associate with the post. On update, this fully replaces the existing tags array (not merged).',
63
+ }),
64
+ authors: authorsSchema.meta({
65
+ description:
66
+ 'Array of author IDs or emails. On update, this fully replaces the existing authors array (not merged).',
67
+ }),
68
+ published_at: isoDateSchema
69
+ .optional()
70
+ .meta({ description: 'Scheduled publish time (ISO 8601 format)' }),
65
71
  codeinjection_head: z.string().optional(),
66
72
  codeinjection_foot: z.string().optional(),
67
- custom_template: z.string().optional().describe('Custom template filename'),
68
- email_only: z.boolean().default(false).describe('Whether post is email-only'),
73
+ custom_template: z.string().optional().meta({ description: 'Custom template filename' }),
74
+ email_only: z.boolean().default(false).meta({ description: 'Whether post is email-only' }),
69
75
  });
70
76
 
71
77
  /**
@@ -84,17 +90,20 @@ export const postQuerySchema = z.object({
84
90
  .string()
85
91
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
86
92
  .optional()
87
- .describe('NQL filter string (e.g., "status:published+featured:true")'),
93
+ .meta({ description: 'NQL filter string (e.g., "status:published+featured:true")' }),
88
94
  include: z
89
95
  .string()
90
96
  .optional()
91
- .describe('Comma-separated list of relations (e.g., "tags,authors")'),
92
- fields: z.string().optional().describe('Comma-separated list of fields to return'),
97
+ .meta({ description: 'Comma-separated list of relations (e.g., "tags,authors")' }),
98
+ fields: z.string().optional().meta({ description: 'Comma-separated list of fields to return' }),
93
99
  formats: z
94
100
  .string()
95
101
  .optional()
96
- .describe('Comma-separated list of formats (html, plaintext, mobiledoc)'),
97
- order: z.string().optional().describe('Order results (e.g., "published_at DESC", "title ASC")'),
102
+ .meta({ description: 'Comma-separated list of formats (html, plaintext, mobiledoc)' }),
103
+ order: z
104
+ .string()
105
+ .optional()
106
+ .meta({ description: 'Order results (e.g., "published_at DESC", "title ASC")' }),
98
107
  });
99
108
 
100
109
  /**
@@ -68,9 +68,11 @@ export const tagQueryBaseSchema = z.object({
68
68
  'Tag name contains invalid characters. Only letters, numbers, spaces, hyphens, underscores, and apostrophes are allowed'
69
69
  )
70
70
  .optional()
71
- .describe('Filter by exact tag name (legacy parameter, converted to filter internally)'),
72
- slug: z.string().optional().describe('Filter by tag slug'),
73
- visibility: visibilitySchema.optional().describe('Filter by visibility'),
71
+ .meta({
72
+ description: 'Filter by exact tag name (legacy parameter, converted to filter internally)',
73
+ }),
74
+ slug: z.string().optional().meta({ description: 'Filter by tag slug' }),
75
+ visibility: visibilitySchema.optional().meta({ description: 'Filter by visibility' }),
74
76
  limit: z
75
77
  .union([
76
78
  z.number().int().min(1).max(100),
@@ -79,7 +81,7 @@ export const tagQueryBaseSchema = z.object({
79
81
  ])
80
82
  .default(15)
81
83
  .optional()
82
- .describe('Number of tags to return (1-100) or "all" for all tags'),
84
+ .meta({ description: 'Number of tags to return (1-100) or "all" for all tags' }),
83
85
  page: z
84
86
  .union([z.number().int().min(1), z.string().regex(/^\d+$/).transform(Number)])
85
87
  .default(1)
@@ -88,12 +90,15 @@ export const tagQueryBaseSchema = z.object({
88
90
  .string()
89
91
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
90
92
  .optional()
91
- .describe('NQL filter string'),
93
+ .meta({ description: 'NQL filter string' }),
92
94
  include: z
93
95
  .string()
94
96
  .optional()
95
- .describe('Comma-separated list of relations to include (e.g., "count.posts")'),
96
- order: z.string().optional().describe('Order results (e.g., "name ASC", "created_at DESC")'),
97
+ .meta({ description: 'Comma-separated list of relations to include (e.g., "count.posts")' }),
98
+ order: z
99
+ .string()
100
+ .optional()
101
+ .meta({ description: 'Order results (e.g., "name ASC", "created_at DESC")' }),
97
102
  });
98
103
 
99
104
  /**
@@ -47,16 +47,19 @@ export const createTierSchema = z.object({
47
47
  name: z.string().min(1, 'Name cannot be empty').max(191, 'Name cannot exceed 191 characters'),
48
48
  description: z.string().max(2000, 'Description cannot exceed 2000 characters').optional(),
49
49
  slug: slugSchema.optional(),
50
- active: z.boolean().default(true).describe('Whether tier is currently active/available'),
50
+ active: z
51
+ .boolean()
52
+ .default(true)
53
+ .meta({ description: 'Whether tier is currently active/available' }),
51
54
  type: z
52
55
  .enum(['free', 'paid'], {
53
- errorMap: () => ({ message: 'Type must be free or paid' }),
56
+ error: () => ({ message: 'Type must be free or paid' }),
54
57
  })
55
58
  .default('paid'),
56
59
  welcome_page_url: z.string().url('Invalid welcome page URL').optional(),
57
60
  visibility: z
58
61
  .enum(['public', 'none'], {
59
- errorMap: () => ({ message: 'Visibility must be public or none' }),
62
+ error: () => ({ message: 'Visibility must be public or none' }),
60
63
  })
61
64
  .default('public'),
62
65
  trial_days: z
@@ -64,7 +67,7 @@ export const createTierSchema = z.object({
64
67
  .int()
65
68
  .min(0, 'Trial days must be non-negative')
66
69
  .default(0)
67
- .describe('Number of trial days for paid tiers'),
70
+ .meta({ description: 'Number of trial days for paid tiers' }),
68
71
  currency: z
69
72
  .string()
70
73
  .length(3, 'Currency must be 3-letter ISO code')
@@ -72,7 +75,10 @@ export const createTierSchema = z.object({
72
75
  .optional(),
73
76
  monthly_price: z.number().int().min(0, 'Monthly price must be non-negative').optional(),
74
77
  yearly_price: z.number().int().min(0, 'Yearly price must be non-negative').optional(),
75
- benefits: z.array(z.string()).optional().describe('Array of benefit names/descriptions'),
78
+ benefits: z
79
+ .array(z.string())
80
+ .optional()
81
+ .meta({ description: 'Array of benefit names/descriptions' }),
76
82
  });
77
83
 
78
84
  /**
@@ -91,12 +97,15 @@ export const tierQuerySchema = z.object({
91
97
  .string()
92
98
  .regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
93
99
  .optional()
94
- .describe('NQL filter string (e.g., "type:paid+active:true")'),
95
- include: z.string().optional().describe('Comma-separated list of relations to include'),
100
+ .meta({ description: 'NQL filter string (e.g., "type:paid+active:true")' }),
101
+ include: z
102
+ .string()
103
+ .optional()
104
+ .meta({ description: 'Comma-separated list of relations to include' }),
96
105
  order: z
97
106
  .string()
98
107
  .optional()
99
- .describe('Order results (e.g., "monthly_price ASC", "created_at DESC")'),
108
+ .meta({ description: 'Order results (e.g., "monthly_price ASC", "created_at DESC")' }),
100
109
  });
101
110
 
102
111
  /**
@@ -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,20 @@ 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
- }),
177
- expect.objectContaining({ id: memberId })
176
+ {
177
+ id: memberId,
178
+ name: 'Jane Doe',
179
+ note: 'Updated note',
180
+ updated_at: '2023-01-01T00:00:00.000Z',
181
+ },
182
+ {}
178
183
  );
184
+ // Verify read-only fields are NOT sent
185
+ const editCallData = api.members.edit.mock.calls[0][0];
186
+ expect(editCallData).not.toHaveProperty('uuid');
187
+ expect(editCallData).not.toHaveProperty('status');
179
188
  expect(result).toEqual(mockUpdatedMember);
180
189
  });
181
190
 
@@ -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
- },
218
- { id: 'newsletter-123' }
217
+ { id: 'newsletter-123', name: 'New Name', updated_at: '2024-01-01T00:00:00.000Z' },
218
+ {}
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
+ { id: 'newsletter-123', ...updateData, updated_at: '2024-01-01T00:00:00.000Z' },
246
+ {}
247
+ );
242
248
  });
243
249
 
244
250
  it('should throw ValidationError if ID is missing', async () => {
@@ -270,11 +276,14 @@ 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({
281
+ {
282
+ id: 'newsletter-123',
283
+ description: 'Updated description',
275
284
  updated_at: '2024-01-01T00:00:00.000Z',
276
- }),
277
- { id: 'newsletter-123' }
285
+ },
286
+ {}
278
287
  );
279
288
  });
280
289
  });
@@ -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 },
290
- { id: pageId }
295
+ { id: pageId, title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
296
+ {}
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,23 +328,25 @@ 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
 
333
346
  describe('deletePage', () => {
334
347
  it('should throw error when page ID is missing', async () => {
335
- await expect(deletePage(null)).rejects.toThrow('Page ID is required');
336
- await expect(deletePage('')).rejects.toThrow('Page ID is required');
348
+ await expect(deletePage(null)).rejects.toThrow('Page ID is required for deletion');
349
+ await expect(deletePage('')).rejects.toThrow('Page ID is required for deletion');
337
350
  });
338
351
 
339
352
  it('should delete page successfully', async () => {
@@ -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
  });
@@ -0,0 +1,233 @@
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 posts 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
+ site: {
38
+ read: vi.fn(),
39
+ },
40
+ images: {
41
+ upload: vi.fn(),
42
+ },
43
+ };
44
+ }),
45
+ }));
46
+
47
+ // Mock dotenv
48
+ vi.mock('dotenv', () => mockDotenv());
49
+
50
+ // Mock logger
51
+ vi.mock('../../utils/logger.js', () => ({
52
+ createContextLogger: createMockContextLogger(),
53
+ }));
54
+
55
+ // Mock fs for validateImagePath
56
+ vi.mock('fs/promises', () => ({
57
+ default: {
58
+ access: vi.fn(),
59
+ },
60
+ }));
61
+
62
+ // Import after setting up mocks
63
+ import { updatePost, api, validators } from '../ghostServiceImproved.js';
64
+ import { updatePostSchema } from '../../schemas/postSchemas.js';
65
+
66
+ describe('ghostServiceImproved - Posts (updatePost)', () => {
67
+ beforeEach(() => {
68
+ vi.clearAllMocks();
69
+ });
70
+
71
+ describe('updatePost', () => {
72
+ it('should send only update fields and updated_at, not the full existing post', async () => {
73
+ const postId = 'post-123';
74
+ const existingPost = {
75
+ id: postId,
76
+ uuid: 'abc-def-123',
77
+ title: 'Original Title',
78
+ slug: 'original-title',
79
+ html: '<p>Original content</p>',
80
+ status: 'published',
81
+ url: 'https://example.com/original-title',
82
+ comment_id: 'comment-123',
83
+ reading_time: 3,
84
+ updated_at: '2024-01-01T00:00:00.000Z',
85
+ created_at: '2023-12-01T00:00:00.000Z',
86
+ };
87
+ const updateData = { title: 'Updated Title' };
88
+ const expectedResult = { ...existingPost, title: 'Updated Title' };
89
+
90
+ api.posts.read.mockResolvedValue(existingPost);
91
+ api.posts.edit.mockResolvedValue(expectedResult);
92
+
93
+ const result = await updatePost(postId, updateData);
94
+
95
+ expect(result).toEqual(expectedResult);
96
+ // Should send ONLY updateData + updated_at, NOT the full existing post
97
+ expect(api.posts.edit).toHaveBeenCalledWith(
98
+ { id: postId, title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
99
+ {}
100
+ );
101
+ // Verify read-only fields are NOT sent
102
+ const editCallData = api.posts.edit.mock.calls[0][0];
103
+ expect(editCallData).not.toHaveProperty('uuid');
104
+ expect(editCallData).not.toHaveProperty('url');
105
+ expect(editCallData).not.toHaveProperty('comment_id');
106
+ expect(editCallData).not.toHaveProperty('reading_time');
107
+ expect(editCallData).not.toHaveProperty('created_at');
108
+ });
109
+
110
+ it('should preserve updated_at from existing post for OCC', async () => {
111
+ const postId = 'post-123';
112
+ const existingPost = {
113
+ id: postId,
114
+ title: 'Original',
115
+ updated_at: '2024-06-15T12:30:00.000Z',
116
+ };
117
+ api.posts.read.mockResolvedValue(existingPost);
118
+ api.posts.edit.mockResolvedValue({ ...existingPost, title: 'Updated' });
119
+
120
+ await updatePost(postId, { title: 'Updated' });
121
+
122
+ const editCall = api.posts.edit.mock.calls[0][0];
123
+ expect(editCall.updated_at).toBe('2024-06-15T12:30:00.000Z');
124
+ });
125
+
126
+ it('should throw error when post ID is missing', async () => {
127
+ await expect(updatePost(null, { title: 'Updated' })).rejects.toThrow(
128
+ 'Post ID is required for update'
129
+ );
130
+ await expect(updatePost('', { title: 'Updated' })).rejects.toThrow(
131
+ 'Post ID is required for update'
132
+ );
133
+ });
134
+
135
+ it('should handle post not found (404)', async () => {
136
+ const error404 = new Error('Post not found');
137
+ error404.response = { status: 404 };
138
+ api.posts.read.mockRejectedValue(error404);
139
+
140
+ await expect(updatePost('nonexistent-id', { title: 'Updated' })).rejects.toThrow(
141
+ 'Post not found'
142
+ );
143
+ });
144
+
145
+ it('should throw ValidationError when updating to scheduled without published_at', async () => {
146
+ const postId = 'post-123';
147
+ const existingPost = {
148
+ id: postId,
149
+ title: 'Test Post',
150
+ updated_at: '2024-01-01T00:00:00.000Z',
151
+ };
152
+ api.posts.read.mockResolvedValue(existingPost);
153
+
154
+ await expect(updatePost(postId, { status: 'scheduled' })).rejects.toThrow(
155
+ 'Post validation failed'
156
+ );
157
+ });
158
+
159
+ it('should allow updating to scheduled with valid future published_at', async () => {
160
+ const postId = 'post-123';
161
+ const existingPost = {
162
+ id: postId,
163
+ title: 'Test Post',
164
+ updated_at: '2024-01-01T00:00:00.000Z',
165
+ };
166
+ const futureDate = new Date(Date.now() + 86400000).toISOString();
167
+ const updateData = { status: 'scheduled', published_at: futureDate };
168
+
169
+ api.posts.read.mockResolvedValue(existingPost);
170
+ api.posts.edit.mockResolvedValue({ ...existingPost, ...updateData });
171
+
172
+ const result = await updatePost(postId, updateData);
173
+
174
+ expect(result).toBeDefined();
175
+ expect(api.posts.edit).toHaveBeenCalledWith(
176
+ { id: postId, ...updateData, updated_at: existingPost.updated_at },
177
+ {}
178
+ );
179
+ });
180
+ });
181
+
182
+ describe('validators.validateScheduledStatus', () => {
183
+ it('should throw when status is scheduled without published_at', () => {
184
+ expect(() => validators.validateScheduledStatus({ status: 'scheduled' })).toThrow(
185
+ 'validation failed'
186
+ );
187
+ });
188
+
189
+ it('should throw when published_at has invalid date format', () => {
190
+ expect(() => validators.validateScheduledStatus({ published_at: 'not-a-date' })).toThrow(
191
+ 'validation failed'
192
+ );
193
+ });
194
+
195
+ it('should throw when scheduled date is in the past', () => {
196
+ const pastDate = new Date(Date.now() - 86400000).toISOString();
197
+ expect(() =>
198
+ validators.validateScheduledStatus({ status: 'scheduled', published_at: pastDate })
199
+ ).toThrow('validation failed');
200
+ });
201
+
202
+ it('should not throw for valid scheduled status with future date', () => {
203
+ const futureDate = new Date(Date.now() + 86400000).toISOString();
204
+ expect(() =>
205
+ validators.validateScheduledStatus({ status: 'scheduled', published_at: futureDate })
206
+ ).not.toThrow();
207
+ });
208
+
209
+ it('should not throw when status is not scheduled', () => {
210
+ expect(() => validators.validateScheduledStatus({ status: 'draft' })).not.toThrow();
211
+ expect(() => validators.validateScheduledStatus({ status: 'published' })).not.toThrow();
212
+ expect(() => validators.validateScheduledStatus({})).not.toThrow();
213
+ });
214
+ });
215
+
216
+ describe('HTML sanitization (schema + service integration)', () => {
217
+ it('should strip XSS from post HTML on update when input flows through schema validation (production path)', async () => {
218
+ const rawUpdate = { html: '<p>Safe</p><script>alert("xss")</script>' };
219
+ const validated = updatePostSchema.parse(rawUpdate);
220
+
221
+ const existingPost = { id: 'post-1', updated_at: '2024-01-01T00:00:00.000Z' };
222
+ api.posts.read.mockResolvedValue(existingPost);
223
+ api.posts.edit.mockResolvedValue({ ...existingPost, ...validated });
224
+
225
+ await updatePost('post-1', validated);
226
+
227
+ const sentToApi = api.posts.edit.mock.calls[0][0];
228
+ expect(sentToApi.html).not.toContain('<script>');
229
+ expect(sentToApi.html).not.toContain('alert');
230
+ expect(sentToApi.html).toContain('<p>Safe</p>');
231
+ });
232
+ });
233
+ });