@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.
- package/package.json +6 -18
- package/src/__tests__/mcp_server.test.js +204 -117
- package/src/__tests__/mcp_server_pages.test.js +32 -18
- package/src/config/mcp-config.js +1 -1
- package/src/controllers/__tests__/tagController.test.js +12 -8
- package/src/controllers/tagController.js +2 -2
- package/src/errors/__tests__/index.test.js +3 -3
- package/src/errors/index.js +1 -1
- package/src/index.js +1 -1
- package/src/mcp_server.js +37 -33
- package/src/schemas/__tests__/postSchemas.test.js +19 -0
- package/src/schemas/__tests__/tagSchemas.test.js +1 -1
- package/src/schemas/common.js +2 -2
- package/src/schemas/memberSchemas.js +20 -8
- package/src/schemas/newsletterSchemas.js +10 -10
- package/src/schemas/pageSchemas.js +16 -13
- package/src/schemas/postSchemas.js +22 -13
- package/src/schemas/tagSchemas.js +12 -7
- package/src/schemas/tierSchemas.js +17 -8
- package/src/services/__tests__/ghostServiceImproved.members.test.js +15 -6
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +21 -12
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +59 -16
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +233 -0
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +13 -2
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +18 -19
- package/src/services/__tests__/memberService.test.js +0 -28
- package/src/services/__tests__/tierService.test.js +0 -28
- package/src/services/ghostServiceImproved.js +108 -379
- package/src/services/imageProcessingService.js +1 -1
- package/src/services/memberService.js +0 -13
- package/src/services/tierService.js +0 -13
- package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
- package/src/utils/__tests__/urlValidator.test.js +137 -1
- package/src/utils/nqlSanitizer.js +11 -0
- 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.
|
|
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.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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().
|
|
68
|
-
email_only: z.boolean().default(false).
|
|
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
|
-
.
|
|
93
|
+
.meta({ description: 'NQL filter string (e.g., "status:published+featured:true")' }),
|
|
88
94
|
include: z
|
|
89
95
|
.string()
|
|
90
96
|
.optional()
|
|
91
|
-
.
|
|
92
|
-
fields: z.string().optional().
|
|
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
|
-
.
|
|
97
|
-
order: z
|
|
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
|
-
.
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
93
|
+
.meta({ description: 'NQL filter string' }),
|
|
92
94
|
include: z
|
|
93
95
|
.string()
|
|
94
96
|
.optional()
|
|
95
|
-
.
|
|
96
|
-
order: z
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
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
|
-
.
|
|
95
|
-
include: z
|
|
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
|
-
.
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
281
|
+
{
|
|
282
|
+
id: 'newsletter-123',
|
|
283
|
+
description: 'Updated description',
|
|
275
284
|
updated_at: '2024-01-01T00:00:00.000Z',
|
|
276
|
-
}
|
|
277
|
-
{
|
|
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
|
|
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
|
-
//
|
|
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).
|
|
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
|
|
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
|
-
{
|
|
290
|
-
{
|
|
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
|
|
331
|
+
it('should throw ValidationError when updating to scheduled without published_at', async () => {
|
|
321
332
|
const pageId = 'page-123';
|
|
322
|
-
const existingPage = {
|
|
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, {
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
});
|