@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.
@@ -177,6 +177,90 @@ describe('Common Schemas', () => {
177
177
  it('should reject empty strings', () => {
178
178
  expect(() => htmlContentSchema.parse('')).toThrow();
179
179
  });
180
+
181
+ // XSS Prevention Tests
182
+ describe('XSS sanitization', () => {
183
+ it('should strip script tags', () => {
184
+ const result = htmlContentSchema.parse('<p>Safe</p><script>alert("xss")</script>');
185
+ expect(result).not.toContain('<script>');
186
+ expect(result).not.toContain('alert');
187
+ expect(result).toContain('<p>Safe</p>');
188
+ });
189
+
190
+ it('should strip onclick and other event handlers', () => {
191
+ const result = htmlContentSchema.parse('<p onclick="alert(1)">Click me</p>');
192
+ expect(result).not.toContain('onclick');
193
+ expect(result).toContain('<p>Click me</p>');
194
+ });
195
+
196
+ it('should strip javascript: URLs', () => {
197
+ const result = htmlContentSchema.parse('<a href="javascript:alert(1)">Link</a>');
198
+ expect(result).not.toContain('javascript:');
199
+ });
200
+
201
+ it('should strip onerror handlers on images', () => {
202
+ const result = htmlContentSchema.parse('<img src="x" onerror="alert(1)">');
203
+ expect(result).not.toContain('onerror');
204
+ });
205
+
206
+ it('should allow safe tags', () => {
207
+ const safeHtml =
208
+ '<h1>Title</h1><p>Paragraph</p><a href="https://example.com">Link</a><ul><li>Item</li></ul>';
209
+ const result = htmlContentSchema.parse(safeHtml);
210
+ expect(result).toContain('<h1>');
211
+ expect(result).toContain('<p>');
212
+ expect(result).toContain('<a ');
213
+ expect(result).toContain('<ul>');
214
+ expect(result).toContain('<li>');
215
+ });
216
+
217
+ it('should allow safe attributes on links', () => {
218
+ const result = htmlContentSchema.parse(
219
+ '<a href="https://example.com" title="Example">Link</a>'
220
+ );
221
+ expect(result).toContain('href="https://example.com"');
222
+ expect(result).toContain('title="Example"');
223
+ });
224
+
225
+ it('should allow safe attributes on images', () => {
226
+ const result = htmlContentSchema.parse(
227
+ '<img src="https://example.com/img.jpg" alt="Description" title="Title" width="100" height="100">'
228
+ );
229
+ expect(result).toContain('src="https://example.com/img.jpg"');
230
+ expect(result).toContain('alt="Description"');
231
+ });
232
+
233
+ it('should strip style attributes by default', () => {
234
+ const result = htmlContentSchema.parse('<p style="color: red">Styled</p>');
235
+ expect(result).not.toContain('style=');
236
+ });
237
+
238
+ it('should strip iframe tags', () => {
239
+ const result = htmlContentSchema.parse(
240
+ '<iframe src="https://evil.com"></iframe><p>Safe</p>'
241
+ );
242
+ expect(result).not.toContain('<iframe');
243
+ expect(result).toContain('<p>Safe</p>');
244
+ });
245
+
246
+ it('should strip data: URLs on images', () => {
247
+ // data: URLs can be used for XSS in some contexts
248
+ const result = htmlContentSchema.parse(
249
+ '<img src="data:text/html,<script>alert(1)</script>">'
250
+ );
251
+ // The src should either be removed or the tag stripped
252
+ expect(result).not.toContain('<script>');
253
+ });
254
+
255
+ it('should preserve text content while stripping dangerous elements', () => {
256
+ const result = htmlContentSchema.parse(
257
+ '<div>Safe text<script>evil()</script> more text</div>'
258
+ );
259
+ expect(result).toContain('Safe text');
260
+ expect(result).toContain('more text');
261
+ expect(result).not.toContain('evil');
262
+ });
263
+ });
180
264
  });
181
265
 
182
266
  describe('titleSchema', () => {
@@ -0,0 +1,447 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ createMemberSchema,
4
+ updateMemberSchema,
5
+ memberQuerySchema,
6
+ memberIdSchema,
7
+ memberEmailSchema,
8
+ memberOutputSchema,
9
+ labelOutputSchema,
10
+ newsletterOutputSchema,
11
+ memberSubscriptionSchema,
12
+ } from '../memberSchemas.js';
13
+
14
+ describe('Member Schemas', () => {
15
+ describe('createMemberSchema', () => {
16
+ it('should accept valid member creation data', () => {
17
+ const validMember = {
18
+ email: 'member@example.com',
19
+ name: 'John Doe',
20
+ subscribed: true,
21
+ };
22
+
23
+ expect(() => createMemberSchema.parse(validMember)).not.toThrow();
24
+ });
25
+
26
+ it('should accept minimal member creation data (email only)', () => {
27
+ const minimalMember = {
28
+ email: 'minimal@example.com',
29
+ };
30
+
31
+ const result = createMemberSchema.parse(minimalMember);
32
+ expect(result.email).toBe('minimal@example.com');
33
+ expect(result.subscribed).toBe(true); // default
34
+ expect(result.comped).toBe(false); // default
35
+ });
36
+
37
+ it('should accept member with all fields', () => {
38
+ const fullMember = {
39
+ email: 'full@example.com',
40
+ name: 'Jane Smith',
41
+ note: 'VIP customer',
42
+ subscribed: true,
43
+ comped: true,
44
+ labels: ['vip', 'newsletter'],
45
+ newsletters: ['507f1f77bcf86cd799439011'],
46
+ };
47
+
48
+ expect(() => createMemberSchema.parse(fullMember)).not.toThrow();
49
+ });
50
+
51
+ it('should reject member without email', () => {
52
+ const invalidMember = {
53
+ name: 'No Email',
54
+ };
55
+
56
+ expect(() => createMemberSchema.parse(invalidMember)).toThrow();
57
+ });
58
+
59
+ it('should reject member with invalid email', () => {
60
+ const invalidMember = {
61
+ email: 'not-an-email',
62
+ };
63
+
64
+ expect(() => createMemberSchema.parse(invalidMember)).toThrow();
65
+ });
66
+
67
+ it('should reject member with too long name', () => {
68
+ const invalidMember = {
69
+ email: 'test@example.com',
70
+ name: 'A'.repeat(192),
71
+ };
72
+
73
+ expect(() => createMemberSchema.parse(invalidMember)).toThrow();
74
+ });
75
+
76
+ it('should reject member with too long note', () => {
77
+ const invalidMember = {
78
+ email: 'test@example.com',
79
+ note: 'A'.repeat(2001),
80
+ };
81
+
82
+ expect(() => createMemberSchema.parse(invalidMember)).toThrow();
83
+ });
84
+
85
+ it('should reject member with invalid newsletter ID', () => {
86
+ const invalidMember = {
87
+ email: 'test@example.com',
88
+ newsletters: ['invalid-id'],
89
+ };
90
+
91
+ expect(() => createMemberSchema.parse(invalidMember)).toThrow();
92
+ });
93
+ });
94
+
95
+ describe('updateMemberSchema', () => {
96
+ it('should accept partial member updates', () => {
97
+ const update = {
98
+ name: 'Updated Name',
99
+ };
100
+
101
+ expect(() => updateMemberSchema.parse(update)).not.toThrow();
102
+ });
103
+
104
+ it('should accept empty update object', () => {
105
+ expect(() => updateMemberSchema.parse({})).not.toThrow();
106
+ });
107
+
108
+ it('should accept full member update', () => {
109
+ const update = {
110
+ email: 'updated@example.com',
111
+ name: 'Updated Name',
112
+ note: 'Updated note',
113
+ subscribed: false,
114
+ };
115
+
116
+ expect(() => updateMemberSchema.parse(update)).not.toThrow();
117
+ });
118
+ });
119
+
120
+ describe('memberQuerySchema', () => {
121
+ it('should accept valid query parameters', () => {
122
+ const query = {
123
+ limit: 20,
124
+ page: 2,
125
+ filter: 'status:paid+subscribed:true',
126
+ };
127
+
128
+ expect(() => memberQuerySchema.parse(query)).not.toThrow();
129
+ });
130
+
131
+ it('should accept query with include parameter', () => {
132
+ const query = {
133
+ include: 'labels,newsletters',
134
+ };
135
+
136
+ expect(() => memberQuerySchema.parse(query)).not.toThrow();
137
+ });
138
+
139
+ it('should accept query with search parameter', () => {
140
+ const query = {
141
+ search: 'john',
142
+ limit: 10,
143
+ };
144
+
145
+ expect(() => memberQuerySchema.parse(query)).not.toThrow();
146
+ });
147
+
148
+ it('should accept query with order parameter', () => {
149
+ const query = {
150
+ order: 'created_at DESC',
151
+ };
152
+
153
+ expect(() => memberQuerySchema.parse(query)).not.toThrow();
154
+ });
155
+
156
+ it('should reject query with invalid filter characters', () => {
157
+ const query = {
158
+ filter: 'status;DROP TABLE',
159
+ };
160
+
161
+ expect(() => memberQuerySchema.parse(query)).toThrow();
162
+ });
163
+
164
+ it('should accept empty query object', () => {
165
+ const result = memberQuerySchema.parse({});
166
+ expect(result).toBeDefined();
167
+ });
168
+ });
169
+
170
+ describe('memberIdSchema', () => {
171
+ it('should accept valid Ghost ID', () => {
172
+ const validId = {
173
+ id: '507f1f77bcf86cd799439011',
174
+ };
175
+
176
+ expect(() => memberIdSchema.parse(validId)).not.toThrow();
177
+ });
178
+
179
+ it('should reject invalid Ghost ID', () => {
180
+ const invalidId = {
181
+ id: 'invalid-id',
182
+ };
183
+
184
+ expect(() => memberIdSchema.parse(invalidId)).toThrow();
185
+ });
186
+ });
187
+
188
+ describe('memberEmailSchema', () => {
189
+ it('should accept valid email', () => {
190
+ const validEmail = {
191
+ email: 'test@example.com',
192
+ };
193
+
194
+ expect(() => memberEmailSchema.parse(validEmail)).not.toThrow();
195
+ });
196
+
197
+ it('should reject invalid email', () => {
198
+ const invalidEmail = {
199
+ email: 'not-an-email',
200
+ };
201
+
202
+ expect(() => memberEmailSchema.parse(invalidEmail)).toThrow();
203
+ });
204
+ });
205
+
206
+ describe('labelOutputSchema', () => {
207
+ it('should accept valid label output from Ghost API', () => {
208
+ const apiLabel = {
209
+ id: '507f1f77bcf86cd799439011',
210
+ name: 'VIP',
211
+ slug: 'vip',
212
+ created_at: '2024-01-15T10:30:00.000Z',
213
+ updated_at: '2024-01-15T10:30:00.000Z',
214
+ };
215
+
216
+ expect(() => labelOutputSchema.parse(apiLabel)).not.toThrow();
217
+ });
218
+
219
+ it('should reject label output without required fields', () => {
220
+ const invalidLabel = {
221
+ name: 'VIP',
222
+ slug: 'vip',
223
+ };
224
+
225
+ expect(() => labelOutputSchema.parse(invalidLabel)).toThrow();
226
+ });
227
+ });
228
+
229
+ describe('newsletterOutputSchema', () => {
230
+ it('should accept valid newsletter output from Ghost API', () => {
231
+ const apiNewsletter = {
232
+ id: '507f1f77bcf86cd799439011',
233
+ uuid: '550e8400-e29b-41d4-a716-446655440000',
234
+ name: 'Weekly Newsletter',
235
+ description: 'Our weekly updates',
236
+ slug: 'weekly-newsletter',
237
+ sender_name: 'Blog Team',
238
+ sender_email: 'team@example.com',
239
+ sender_reply_to: 'newsletter',
240
+ status: 'active',
241
+ visibility: 'members',
242
+ subscribe_on_signup: true,
243
+ sort_order: 0,
244
+ header_image: null,
245
+ show_header_icon: true,
246
+ show_header_title: true,
247
+ title_font_category: 'sans-serif',
248
+ title_alignment: 'center',
249
+ show_feature_image: true,
250
+ body_font_category: 'sans-serif',
251
+ footer_content: null,
252
+ show_badge: true,
253
+ created_at: '2024-01-15T10:30:00.000Z',
254
+ updated_at: '2024-01-15T10:30:00.000Z',
255
+ };
256
+
257
+ expect(() => newsletterOutputSchema.parse(apiNewsletter)).not.toThrow();
258
+ });
259
+ });
260
+
261
+ describe('memberSubscriptionSchema', () => {
262
+ it('should accept valid subscription object', () => {
263
+ const subscription = {
264
+ id: 'sub_123',
265
+ customer: {
266
+ id: 'cus_123',
267
+ name: 'John Doe',
268
+ email: 'john@example.com',
269
+ },
270
+ plan: {
271
+ id: 'plan_123',
272
+ nickname: 'Monthly Premium',
273
+ amount: 999,
274
+ interval: 'month',
275
+ currency: 'USD',
276
+ },
277
+ status: 'active',
278
+ start_date: '2024-01-15T10:30:00.000Z',
279
+ current_period_end: '2024-02-15T10:30:00.000Z',
280
+ cancel_at_period_end: false,
281
+ cancellation_reason: null,
282
+ trial_start_date: null,
283
+ trial_end_date: null,
284
+ };
285
+
286
+ expect(() => memberSubscriptionSchema.parse(subscription)).not.toThrow();
287
+ });
288
+
289
+ it('should accept subscription with trial dates', () => {
290
+ const subscription = {
291
+ id: 'sub_123',
292
+ customer: {
293
+ id: 'cus_123',
294
+ name: null,
295
+ email: 'test@example.com',
296
+ },
297
+ plan: {
298
+ id: 'plan_123',
299
+ nickname: 'Yearly Premium',
300
+ amount: 9999,
301
+ interval: 'year',
302
+ currency: 'EUR',
303
+ },
304
+ status: 'trialing',
305
+ start_date: '2024-01-15T10:30:00.000Z',
306
+ current_period_end: '2024-02-15T10:30:00.000Z',
307
+ cancel_at_period_end: false,
308
+ cancellation_reason: null,
309
+ trial_start_date: '2024-01-15T10:30:00.000Z',
310
+ trial_end_date: '2024-01-22T10:30:00.000Z',
311
+ };
312
+
313
+ expect(() => memberSubscriptionSchema.parse(subscription)).not.toThrow();
314
+ });
315
+
316
+ it('should reject subscription with invalid status', () => {
317
+ const subscription = {
318
+ id: 'sub_123',
319
+ customer: {
320
+ id: 'cus_123',
321
+ email: 'test@example.com',
322
+ },
323
+ plan: {
324
+ id: 'plan_123',
325
+ nickname: 'Monthly',
326
+ amount: 999,
327
+ interval: 'month',
328
+ currency: 'USD',
329
+ },
330
+ status: 'invalid_status',
331
+ start_date: '2024-01-15T10:30:00.000Z',
332
+ current_period_end: '2024-02-15T10:30:00.000Z',
333
+ cancel_at_period_end: false,
334
+ };
335
+
336
+ expect(() => memberSubscriptionSchema.parse(subscription)).toThrow();
337
+ });
338
+ });
339
+
340
+ describe('memberOutputSchema', () => {
341
+ it('should accept valid member output from Ghost API', () => {
342
+ const apiMember = {
343
+ id: '507f1f77bcf86cd799439011',
344
+ uuid: '550e8400-e29b-41d4-a716-446655440000',
345
+ email: 'member@example.com',
346
+ name: 'John Doe',
347
+ note: 'VIP customer',
348
+ geolocation: 'US',
349
+ enable_comment_notifications: true,
350
+ email_count: 10,
351
+ email_opened_count: 8,
352
+ email_open_rate: 0.8,
353
+ status: 'paid',
354
+ created_at: '2024-01-15T10:30:00.000Z',
355
+ updated_at: '2024-01-15T10:30:00.000Z',
356
+ subscribed: true,
357
+ comped: false,
358
+ email_suppression: null,
359
+ labels: [],
360
+ subscriptions: [],
361
+ newsletters: [],
362
+ avatar_image: 'https://example.com/avatar.jpg',
363
+ };
364
+
365
+ expect(() => memberOutputSchema.parse(apiMember)).not.toThrow();
366
+ });
367
+
368
+ it('should accept member with null optional fields', () => {
369
+ const apiMember = {
370
+ id: '507f1f77bcf86cd799439011',
371
+ uuid: '550e8400-e29b-41d4-a716-446655440000',
372
+ email: 'test@example.com',
373
+ name: null,
374
+ note: null,
375
+ geolocation: null,
376
+ enable_comment_notifications: false,
377
+ email_count: 0,
378
+ email_opened_count: 0,
379
+ email_open_rate: null,
380
+ status: 'free',
381
+ created_at: '2024-01-15T10:30:00.000Z',
382
+ updated_at: '2024-01-15T10:30:00.000Z',
383
+ subscribed: false,
384
+ comped: false,
385
+ email_suppression: null,
386
+ avatar_image: null,
387
+ };
388
+
389
+ expect(() => memberOutputSchema.parse(apiMember)).not.toThrow();
390
+ });
391
+
392
+ it('should accept member with email suppression', () => {
393
+ const apiMember = {
394
+ id: '507f1f77bcf86cd799439011',
395
+ uuid: '550e8400-e29b-41d4-a716-446655440000',
396
+ email: 'suppressed@example.com',
397
+ name: 'Suppressed User',
398
+ note: null,
399
+ geolocation: null,
400
+ enable_comment_notifications: false,
401
+ email_count: 5,
402
+ email_opened_count: 0,
403
+ email_open_rate: 0,
404
+ status: 'free',
405
+ created_at: '2024-01-15T10:30:00.000Z',
406
+ updated_at: '2024-01-15T10:30:00.000Z',
407
+ subscribed: false,
408
+ comped: false,
409
+ email_suppression: {
410
+ suppressed: true,
411
+ info: 'User unsubscribed',
412
+ },
413
+ avatar_image: null,
414
+ };
415
+
416
+ expect(() => memberOutputSchema.parse(apiMember)).not.toThrow();
417
+ });
418
+
419
+ it('should reject member output with invalid status', () => {
420
+ const invalidMember = {
421
+ id: '507f1f77bcf86cd799439011',
422
+ uuid: '550e8400-e29b-41d4-a716-446655440000',
423
+ email: 'test@example.com',
424
+ name: 'Test',
425
+ enable_comment_notifications: true,
426
+ email_count: 0,
427
+ email_opened_count: 0,
428
+ status: 'invalid_status',
429
+ created_at: '2024-01-15T10:30:00.000Z',
430
+ updated_at: '2024-01-15T10:30:00.000Z',
431
+ subscribed: false,
432
+ comped: false,
433
+ };
434
+
435
+ expect(() => memberOutputSchema.parse(invalidMember)).toThrow();
436
+ });
437
+
438
+ it('should reject member output without required fields', () => {
439
+ const invalidMember = {
440
+ email: 'test@example.com',
441
+ name: 'Test',
442
+ };
443
+
444
+ expect(() => memberOutputSchema.parse(invalidMember)).toThrow();
445
+ });
446
+ });
447
+ });