@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.
- package/package.json +3 -7
- package/src/__tests__/mcp_server.test.js +258 -0
- package/src/__tests__/mcp_server_pages.test.js +21 -6
- package/src/controllers/__tests__/tagController.test.js +118 -3
- package/src/controllers/tagController.js +32 -6
- package/src/mcp_server.js +225 -112
- package/src/resources/ResourceManager.js +6 -2
- package/src/resources/__tests__/ResourceManager.test.js +2 -2
- package/src/schemas/__tests__/tagSchemas.test.js +100 -0
- package/src/schemas/pageSchemas.js +0 -2
- package/src/schemas/postSchemas.js +6 -4
- package/src/schemas/tagSchemas.js +33 -5
- package/src/services/__tests__/ghostService.test.js +30 -23
- package/src/services/__tests__/ghostServiceImproved.members.test.js +9 -5
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +16 -11
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +56 -13
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +233 -0
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +486 -0
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +12 -8
- package/src/services/ghostService.js +21 -18
- package/src/services/ghostServiceImproved.js +77 -194
- package/src/utils/__tests__/urlValidator.test.js +137 -1
- package/src/utils/urlValidator.js +25 -2
|
@@ -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(
|
|
61
|
-
|
|
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
|
-
*
|
|
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
|
|
63
|
-
name: z
|
|
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
|
|
67
|
-
|
|
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
|
|
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:
|
|
289
|
+
expect(api.tags.browse).toHaveBeenCalledWith({ limit: 15 }, {});
|
|
307
290
|
});
|
|
308
291
|
|
|
309
|
-
it('should
|
|
310
|
-
const expectedTags = [{ id: '1', 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
|
|
@@ -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
|
});
|