@jgardner04/ghost-mcp-server 1.11.0 → 1.12.1

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.
@@ -0,0 +1,518 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ createPageSchema,
4
+ updatePageSchema,
5
+ pageQuerySchema,
6
+ pageIdSchema,
7
+ pageSlugSchema,
8
+ pageOutputSchema,
9
+ authorOutputSchema,
10
+ tagOutputSchema,
11
+ } from '../pageSchemas.js';
12
+
13
+ describe('Page Schemas', () => {
14
+ describe('createPageSchema', () => {
15
+ it('should accept valid page creation data', () => {
16
+ const validPage = {
17
+ title: 'About Us',
18
+ html: '<p>This is our about page.</p>',
19
+ status: 'published',
20
+ };
21
+
22
+ expect(() => createPageSchema.parse(validPage)).not.toThrow();
23
+ });
24
+
25
+ it('should accept minimal page creation data', () => {
26
+ const minimalPage = {
27
+ title: 'Contact',
28
+ html: '<p>Contact information</p>',
29
+ };
30
+
31
+ const result = createPageSchema.parse(minimalPage);
32
+ expect(result.title).toBe('Contact');
33
+ expect(result.status).toBe('draft'); // default
34
+ expect(result.visibility).toBe('public'); // default
35
+ expect(result.featured).toBe(false); // default
36
+ expect(result.show_title_and_feature_image).toBe(true); // default
37
+ });
38
+
39
+ it('should accept page with all fields', () => {
40
+ const fullPage = {
41
+ title: 'Complete Page',
42
+ html: '<p>Full content</p>',
43
+ slug: 'complete-page',
44
+ status: 'published',
45
+ visibility: 'members',
46
+ featured: true,
47
+ feature_image: 'https://example.com/image.jpg',
48
+ feature_image_alt: 'Image description',
49
+ feature_image_caption: 'Photo caption',
50
+ excerpt: 'Brief summary',
51
+ custom_excerpt: 'Custom summary',
52
+ meta_title: 'SEO Title',
53
+ meta_description: 'SEO Description',
54
+ og_image: 'https://example.com/og.jpg',
55
+ og_title: 'OG Title',
56
+ og_description: 'OG Description',
57
+ twitter_image: 'https://example.com/twitter.jpg',
58
+ twitter_title: 'Twitter Title',
59
+ twitter_description: 'Twitter Description',
60
+ canonical_url: 'https://example.com/original',
61
+ tags: ['about'],
62
+ authors: ['author@example.com'],
63
+ published_at: '2024-01-15T10:30:00.000Z',
64
+ codeinjection_head: '<script>console.log("head")</script>',
65
+ codeinjection_foot: '<script>console.log("foot")</script>',
66
+ custom_template: 'custom-about.hbs',
67
+ show_title_and_feature_image: false,
68
+ };
69
+
70
+ expect(() => createPageSchema.parse(fullPage)).not.toThrow();
71
+ });
72
+
73
+ it('should reject page without title', () => {
74
+ const invalidPage = {
75
+ html: '<p>Content</p>',
76
+ };
77
+
78
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
79
+ });
80
+
81
+ it('should reject page without html', () => {
82
+ const invalidPage = {
83
+ title: 'Title',
84
+ };
85
+
86
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
87
+ });
88
+
89
+ it('should reject page with invalid status', () => {
90
+ const invalidPage = {
91
+ title: 'Title',
92
+ html: '<p>Content</p>',
93
+ status: 'invalid',
94
+ };
95
+
96
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
97
+ });
98
+
99
+ it('should reject page with too long title', () => {
100
+ const invalidPage = {
101
+ title: 'A'.repeat(256),
102
+ html: '<p>Content</p>',
103
+ };
104
+
105
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
106
+ });
107
+
108
+ it('should reject page with invalid slug', () => {
109
+ const invalidPage = {
110
+ title: 'Page',
111
+ html: '<p>Content</p>',
112
+ slug: 'Invalid_Slug',
113
+ };
114
+
115
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
116
+ });
117
+
118
+ it('should reject page with too long feature_image_caption', () => {
119
+ const invalidPage = {
120
+ title: 'Page',
121
+ html: '<p>Content</p>',
122
+ feature_image_caption: 'A'.repeat(501),
123
+ };
124
+
125
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
126
+ });
127
+
128
+ it('should reject page with too long og_title', () => {
129
+ const invalidPage = {
130
+ title: 'Page',
131
+ html: '<p>Content</p>',
132
+ og_title: 'A'.repeat(301),
133
+ };
134
+
135
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
136
+ });
137
+
138
+ it('should reject page with too long og_description', () => {
139
+ const invalidPage = {
140
+ title: 'Page',
141
+ html: '<p>Content</p>',
142
+ og_description: 'A'.repeat(501),
143
+ };
144
+
145
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
146
+ });
147
+
148
+ it('should reject page with too long twitter_title', () => {
149
+ const invalidPage = {
150
+ title: 'Page',
151
+ html: '<p>Content</p>',
152
+ twitter_title: 'A'.repeat(301),
153
+ };
154
+
155
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
156
+ });
157
+
158
+ it('should reject page with too long twitter_description', () => {
159
+ const invalidPage = {
160
+ title: 'Page',
161
+ html: '<p>Content</p>',
162
+ twitter_description: 'A'.repeat(501),
163
+ };
164
+
165
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
166
+ });
167
+
168
+ it('should reject page with invalid canonical_url', () => {
169
+ const invalidPage = {
170
+ title: 'Page',
171
+ html: '<p>Content</p>',
172
+ canonical_url: 'not-a-url',
173
+ };
174
+
175
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
176
+ });
177
+
178
+ it('should reject page with empty html', () => {
179
+ const invalidPage = {
180
+ title: 'Page',
181
+ html: '',
182
+ };
183
+
184
+ expect(() => createPageSchema.parse(invalidPage)).toThrow();
185
+ });
186
+
187
+ // XSS Prevention Tests for HTML content
188
+ describe('XSS sanitization', () => {
189
+ it('should sanitize script tags in html content', () => {
190
+ const page = {
191
+ title: 'Page',
192
+ html: '<p>Safe</p><script>alert("xss")</script>',
193
+ };
194
+
195
+ const result = createPageSchema.parse(page);
196
+ expect(result.html).not.toContain('<script>');
197
+ expect(result.html).not.toContain('alert');
198
+ expect(result.html).toContain('<p>Safe</p>');
199
+ });
200
+
201
+ it('should sanitize onclick handlers in html content', () => {
202
+ const page = {
203
+ title: 'Page',
204
+ html: '<p onclick="alert(1)">Click me</p>',
205
+ };
206
+
207
+ const result = createPageSchema.parse(page);
208
+ expect(result.html).not.toContain('onclick');
209
+ expect(result.html).toContain('<p>Click me</p>');
210
+ });
211
+ });
212
+ });
213
+
214
+ describe('updatePageSchema', () => {
215
+ it('should accept partial page updates', () => {
216
+ const update = {
217
+ title: 'Updated Title',
218
+ };
219
+
220
+ expect(() => updatePageSchema.parse(update)).not.toThrow();
221
+ });
222
+
223
+ it('should accept empty update object', () => {
224
+ expect(() => updatePageSchema.parse({})).not.toThrow();
225
+ });
226
+
227
+ it('should accept full page update', () => {
228
+ const update = {
229
+ title: 'Updated Page',
230
+ html: '<p>Updated content</p>',
231
+ status: 'published',
232
+ };
233
+
234
+ expect(() => updatePageSchema.parse(update)).not.toThrow();
235
+ });
236
+ });
237
+
238
+ describe('pageQuerySchema', () => {
239
+ it('should accept valid query parameters', () => {
240
+ const query = {
241
+ limit: 20,
242
+ page: 2,
243
+ filter: 'status:published+featured:true',
244
+ };
245
+
246
+ expect(() => pageQuerySchema.parse(query)).not.toThrow();
247
+ });
248
+
249
+ it('should accept query with include parameter', () => {
250
+ const query = {
251
+ include: 'tags,authors',
252
+ };
253
+
254
+ expect(() => pageQuerySchema.parse(query)).not.toThrow();
255
+ });
256
+
257
+ it('should accept query with fields parameter', () => {
258
+ const query = {
259
+ fields: 'title,slug,html',
260
+ };
261
+
262
+ expect(() => pageQuerySchema.parse(query)).not.toThrow();
263
+ });
264
+
265
+ it('should accept query with formats parameter', () => {
266
+ const query = {
267
+ formats: 'html,plaintext',
268
+ };
269
+
270
+ expect(() => pageQuerySchema.parse(query)).not.toThrow();
271
+ });
272
+
273
+ it('should accept query with order parameter', () => {
274
+ const query = {
275
+ order: 'published_at DESC',
276
+ };
277
+
278
+ expect(() => pageQuerySchema.parse(query)).not.toThrow();
279
+ });
280
+
281
+ it('should reject query with invalid filter characters', () => {
282
+ const query = {
283
+ filter: 'status;DROP TABLE',
284
+ };
285
+
286
+ expect(() => pageQuerySchema.parse(query)).toThrow();
287
+ });
288
+
289
+ it('should accept empty query object', () => {
290
+ const result = pageQuerySchema.parse({});
291
+ expect(result).toBeDefined();
292
+ });
293
+ });
294
+
295
+ describe('pageIdSchema', () => {
296
+ it('should accept valid Ghost ID', () => {
297
+ const validId = {
298
+ id: '507f1f77bcf86cd799439011',
299
+ };
300
+
301
+ expect(() => pageIdSchema.parse(validId)).not.toThrow();
302
+ });
303
+
304
+ it('should reject invalid Ghost ID', () => {
305
+ const invalidId = {
306
+ id: 'invalid-id',
307
+ };
308
+
309
+ expect(() => pageIdSchema.parse(invalidId)).toThrow();
310
+ });
311
+ });
312
+
313
+ describe('pageSlugSchema', () => {
314
+ it('should accept valid slug', () => {
315
+ const validSlug = {
316
+ slug: 'about-us',
317
+ };
318
+
319
+ expect(() => pageSlugSchema.parse(validSlug)).not.toThrow();
320
+ });
321
+
322
+ it('should reject invalid slug', () => {
323
+ const invalidSlug = {
324
+ slug: 'About_Us',
325
+ };
326
+
327
+ expect(() => pageSlugSchema.parse(invalidSlug)).toThrow();
328
+ });
329
+ });
330
+
331
+ describe('authorOutputSchema', () => {
332
+ it('should accept valid author output from Ghost API', () => {
333
+ const apiAuthor = {
334
+ id: '507f1f77bcf86cd799439011',
335
+ name: 'John Doe',
336
+ slug: 'john-doe',
337
+ email: 'john@example.com',
338
+ profile_image: 'https://example.com/profile.jpg',
339
+ cover_image: 'https://example.com/cover.jpg',
340
+ bio: 'Writer and blogger',
341
+ website: 'https://johndoe.com',
342
+ location: 'New York',
343
+ facebook: 'johndoe',
344
+ twitter: '@johndoe',
345
+ url: 'https://example.com/author/john-doe',
346
+ };
347
+
348
+ expect(() => authorOutputSchema.parse(apiAuthor)).not.toThrow();
349
+ });
350
+
351
+ it('should accept author with null optional fields', () => {
352
+ const apiAuthor = {
353
+ id: '507f1f77bcf86cd799439011',
354
+ name: 'Jane Smith',
355
+ slug: 'jane-smith',
356
+ profile_image: null,
357
+ cover_image: null,
358
+ bio: null,
359
+ website: null,
360
+ location: null,
361
+ facebook: null,
362
+ twitter: null,
363
+ url: 'https://example.com/author/jane-smith',
364
+ };
365
+
366
+ expect(() => authorOutputSchema.parse(apiAuthor)).not.toThrow();
367
+ });
368
+ });
369
+
370
+ describe('tagOutputSchema', () => {
371
+ it('should accept valid tag output from Ghost API', () => {
372
+ const apiTag = {
373
+ id: '507f1f77bcf86cd799439011',
374
+ name: 'Technology',
375
+ slug: 'technology',
376
+ description: 'Tech posts',
377
+ feature_image: 'https://example.com/image.jpg',
378
+ visibility: 'public',
379
+ url: 'https://example.com/tag/technology',
380
+ };
381
+
382
+ expect(() => tagOutputSchema.parse(apiTag)).not.toThrow();
383
+ });
384
+
385
+ it('should accept tag with null optional fields', () => {
386
+ const apiTag = {
387
+ id: '507f1f77bcf86cd799439011',
388
+ name: 'News',
389
+ slug: 'news',
390
+ description: null,
391
+ feature_image: null,
392
+ visibility: 'public',
393
+ url: 'https://example.com/tag/news',
394
+ };
395
+
396
+ expect(() => tagOutputSchema.parse(apiTag)).not.toThrow();
397
+ });
398
+ });
399
+
400
+ describe('pageOutputSchema', () => {
401
+ it('should accept valid page output from Ghost API', () => {
402
+ const apiPage = {
403
+ id: '507f1f77bcf86cd799439011',
404
+ uuid: '550e8400-e29b-41d4-a716-446655440000',
405
+ title: 'About Us',
406
+ slug: 'about-us',
407
+ html: '<p>About content</p>',
408
+ comment_id: null,
409
+ feature_image: 'https://example.com/image.jpg',
410
+ feature_image_alt: 'Alt text',
411
+ feature_image_caption: 'Caption',
412
+ featured: false,
413
+ status: 'published',
414
+ visibility: 'public',
415
+ created_at: '2024-01-15T10:30:00.000Z',
416
+ updated_at: '2024-01-15T10:30:00.000Z',
417
+ published_at: '2024-01-15T10:30:00.000Z',
418
+ custom_excerpt: 'Excerpt',
419
+ codeinjection_head: null,
420
+ codeinjection_foot: null,
421
+ custom_template: null,
422
+ canonical_url: null,
423
+ url: 'https://example.com/about-us',
424
+ excerpt: 'Auto excerpt',
425
+ reading_time: 3,
426
+ og_image: null,
427
+ og_title: null,
428
+ og_description: null,
429
+ twitter_image: null,
430
+ twitter_title: null,
431
+ twitter_description: null,
432
+ meta_title: null,
433
+ meta_description: null,
434
+ show_title_and_feature_image: true,
435
+ authors: [],
436
+ tags: [],
437
+ primary_author: null,
438
+ primary_tag: null,
439
+ };
440
+
441
+ expect(() => pageOutputSchema.parse(apiPage)).not.toThrow();
442
+ });
443
+
444
+ it('should accept page with authors and tags', () => {
445
+ const apiPage = {
446
+ id: '507f1f77bcf86cd799439011',
447
+ uuid: '550e8400-e29b-41d4-a716-446655440000',
448
+ title: 'Contact',
449
+ slug: 'contact',
450
+ html: '<p>Contact content</p>',
451
+ featured: false,
452
+ status: 'published',
453
+ visibility: 'public',
454
+ created_at: '2024-01-15T10:30:00.000Z',
455
+ updated_at: '2024-01-15T10:30:00.000Z',
456
+ url: 'https://example.com/contact',
457
+ authors: [
458
+ {
459
+ id: '507f1f77bcf86cd799439011',
460
+ name: 'John Doe',
461
+ slug: 'john-doe',
462
+ url: 'https://example.com/author/john-doe',
463
+ },
464
+ ],
465
+ tags: [
466
+ {
467
+ id: '507f1f77bcf86cd799439012',
468
+ name: 'Info',
469
+ slug: 'info',
470
+ visibility: 'public',
471
+ url: 'https://example.com/tag/info',
472
+ },
473
+ ],
474
+ primary_author: {
475
+ id: '507f1f77bcf86cd799439011',
476
+ name: 'John Doe',
477
+ slug: 'john-doe',
478
+ url: 'https://example.com/author/john-doe',
479
+ },
480
+ primary_tag: {
481
+ id: '507f1f77bcf86cd799439012',
482
+ name: 'Info',
483
+ slug: 'info',
484
+ visibility: 'public',
485
+ url: 'https://example.com/tag/info',
486
+ },
487
+ };
488
+
489
+ expect(() => pageOutputSchema.parse(apiPage)).not.toThrow();
490
+ });
491
+
492
+ it('should reject page output without required fields', () => {
493
+ const invalidPage = {
494
+ title: 'About',
495
+ slug: 'about',
496
+ };
497
+
498
+ expect(() => pageOutputSchema.parse(invalidPage)).toThrow();
499
+ });
500
+
501
+ it('should reject page output with invalid status', () => {
502
+ const invalidPage = {
503
+ id: '507f1f77bcf86cd799439011',
504
+ uuid: '550e8400-e29b-41d4-a716-446655440000',
505
+ title: 'About',
506
+ slug: 'about',
507
+ featured: false,
508
+ status: 'invalid_status',
509
+ visibility: 'public',
510
+ created_at: '2024-01-15T10:30:00.000Z',
511
+ updated_at: '2024-01-15T10:30:00.000Z',
512
+ url: 'https://example.com/about',
513
+ };
514
+
515
+ expect(() => pageOutputSchema.parse(invalidPage)).toThrow();
516
+ });
517
+ });
518
+ });