@jgardner04/ghost-mcp-server 1.12.5 → 1.13.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.
- package/package.json +3 -3
- 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/tagSchemas.js +33 -5
- package/src/services/__tests__/ghostService.test.js +30 -23
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +475 -0
- package/src/services/ghostService.js +21 -18
- package/src/services/ghostServiceImproved.js +16 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgardner04/ghost-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.1",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
|
|
5
5
|
"author": "Jonathan Gardner",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
21
|
"engines": {
|
|
22
|
-
"node": ">=
|
|
22
|
+
"node": ">=24.0.0"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
54
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
54
|
+
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
55
55
|
"@tryghost/admin-api": "^1.13.12",
|
|
56
56
|
"axios": "^1.12.1",
|
|
57
57
|
"boxen": "^7.1.1",
|
|
@@ -14,6 +14,16 @@ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => {
|
|
|
14
14
|
mockTools.set(name, { name, description, schema, handler });
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
registerTool(name, options, handler) {
|
|
18
|
+
// Store in the same format for backward compatibility with tests
|
|
19
|
+
mockTools.set(name, {
|
|
20
|
+
name,
|
|
21
|
+
description: options.description,
|
|
22
|
+
schema: options.inputSchema,
|
|
23
|
+
handler,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
connect(_transport) {
|
|
18
28
|
return Promise.resolve();
|
|
19
29
|
}
|
|
@@ -208,6 +218,8 @@ describe('mcp_server - ghost_get_posts tool', () => {
|
|
|
208
218
|
expect(tool.schema.shape.include).toBeDefined();
|
|
209
219
|
expect(tool.schema.shape.filter).toBeDefined();
|
|
210
220
|
expect(tool.schema.shape.order).toBeDefined();
|
|
221
|
+
expect(tool.schema.shape.fields).toBeDefined();
|
|
222
|
+
expect(tool.schema.shape.formats).toBeDefined();
|
|
211
223
|
});
|
|
212
224
|
|
|
213
225
|
it('should retrieve posts with default options', async () => {
|
|
@@ -320,6 +332,36 @@ describe('mcp_server - ghost_get_posts tool', () => {
|
|
|
320
332
|
expect(mockGetPosts).toHaveBeenCalledWith({ order: 'published_at DESC' });
|
|
321
333
|
});
|
|
322
334
|
|
|
335
|
+
it('should pass fields parameter', async () => {
|
|
336
|
+
const mockPosts = [{ id: '1', title: 'Test Post', slug: 'test-post' }];
|
|
337
|
+
mockGetPosts.mockResolvedValue(mockPosts);
|
|
338
|
+
|
|
339
|
+
const tool = mockTools.get('ghost_get_posts');
|
|
340
|
+
await tool.handler({ fields: 'id,title,slug' });
|
|
341
|
+
|
|
342
|
+
expect(mockGetPosts).toHaveBeenCalledWith({ fields: 'id,title,slug' });
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should pass formats parameter', async () => {
|
|
346
|
+
const mockPosts = [{ id: '1', title: 'Test Post', html: '<p>Content</p>' }];
|
|
347
|
+
mockGetPosts.mockResolvedValue(mockPosts);
|
|
348
|
+
|
|
349
|
+
const tool = mockTools.get('ghost_get_posts');
|
|
350
|
+
await tool.handler({ formats: 'html,plaintext' });
|
|
351
|
+
|
|
352
|
+
expect(mockGetPosts).toHaveBeenCalledWith({ formats: 'html,plaintext' });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should pass both fields and formats parameters', async () => {
|
|
356
|
+
const mockPosts = [{ id: '1', title: 'Test Post' }];
|
|
357
|
+
mockGetPosts.mockResolvedValue(mockPosts);
|
|
358
|
+
|
|
359
|
+
const tool = mockTools.get('ghost_get_posts');
|
|
360
|
+
await tool.handler({ fields: 'id,title', formats: 'html' });
|
|
361
|
+
|
|
362
|
+
expect(mockGetPosts).toHaveBeenCalledWith({ fields: 'id,title', formats: 'html' });
|
|
363
|
+
});
|
|
364
|
+
|
|
323
365
|
it('should pass all parameters combined', async () => {
|
|
324
366
|
const mockPosts = [{ id: '1', title: 'Test Post' }];
|
|
325
367
|
mockGetPosts.mockResolvedValue(mockPosts);
|
|
@@ -332,6 +374,8 @@ describe('mcp_server - ghost_get_posts tool', () => {
|
|
|
332
374
|
include: 'tags,authors',
|
|
333
375
|
filter: 'featured:true',
|
|
334
376
|
order: 'published_at DESC',
|
|
377
|
+
fields: 'id,title,slug',
|
|
378
|
+
formats: 'html,plaintext',
|
|
335
379
|
});
|
|
336
380
|
|
|
337
381
|
expect(mockGetPosts).toHaveBeenCalledWith({
|
|
@@ -341,6 +385,8 @@ describe('mcp_server - ghost_get_posts tool', () => {
|
|
|
341
385
|
include: 'tags,authors',
|
|
342
386
|
filter: 'featured:true',
|
|
343
387
|
order: 'published_at DESC',
|
|
388
|
+
fields: 'id,title,slug',
|
|
389
|
+
formats: 'html,plaintext',
|
|
344
390
|
});
|
|
345
391
|
});
|
|
346
392
|
|
|
@@ -376,6 +422,218 @@ describe('mcp_server - ghost_get_posts tool', () => {
|
|
|
376
422
|
});
|
|
377
423
|
});
|
|
378
424
|
|
|
425
|
+
describe('mcp_server - ghost_get_tags tool', () => {
|
|
426
|
+
beforeEach(async () => {
|
|
427
|
+
vi.clearAllMocks();
|
|
428
|
+
// Don't clear mockTools - they're registered once on module load
|
|
429
|
+
if (mockTools.size === 0) {
|
|
430
|
+
await import('../mcp_server.js');
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('should register ghost_get_tags tool', () => {
|
|
435
|
+
expect(mockTools.has('ghost_get_tags')).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should have correct schema with all optional parameters', () => {
|
|
439
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
440
|
+
expect(tool).toBeDefined();
|
|
441
|
+
expect(tool.description).toContain('tags');
|
|
442
|
+
expect(tool.schema).toBeDefined();
|
|
443
|
+
// Zod schemas store field definitions in schema.shape
|
|
444
|
+
expect(tool.schema.shape.limit).toBeDefined();
|
|
445
|
+
expect(tool.schema.shape.page).toBeDefined();
|
|
446
|
+
expect(tool.schema.shape.order).toBeDefined();
|
|
447
|
+
expect(tool.schema.shape.include).toBeDefined();
|
|
448
|
+
expect(tool.schema.shape.name).toBeDefined();
|
|
449
|
+
expect(tool.schema.shape.slug).toBeDefined();
|
|
450
|
+
expect(tool.schema.shape.visibility).toBeDefined();
|
|
451
|
+
expect(tool.schema.shape.filter).toBeDefined();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should retrieve tags with default options', async () => {
|
|
455
|
+
const mockTags = [
|
|
456
|
+
{ id: '1', name: 'Tag 1', slug: 'tag-1' },
|
|
457
|
+
{ id: '2', name: 'Tag 2', slug: 'tag-2' },
|
|
458
|
+
];
|
|
459
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
460
|
+
|
|
461
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
462
|
+
const result = await tool.handler({});
|
|
463
|
+
|
|
464
|
+
expect(mockGetTags).toHaveBeenCalledWith({});
|
|
465
|
+
expect(result.content[0].text).toContain('Tag 1');
|
|
466
|
+
expect(result.content[0].text).toContain('Tag 2');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should pass limit and page parameters', async () => {
|
|
470
|
+
const mockTags = [{ id: '1', name: 'Tag 1', slug: 'tag-1' }];
|
|
471
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
472
|
+
|
|
473
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
474
|
+
await tool.handler({ limit: 10, page: 2 });
|
|
475
|
+
|
|
476
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
477
|
+
limit: 10,
|
|
478
|
+
page: 2,
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('should pass order parameter', async () => {
|
|
483
|
+
const mockTags = [{ id: '1', name: 'Tag 1', slug: 'tag-1' }];
|
|
484
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
485
|
+
|
|
486
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
487
|
+
await tool.handler({ order: 'name ASC' });
|
|
488
|
+
|
|
489
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
490
|
+
order: 'name ASC',
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('should pass include parameter', async () => {
|
|
495
|
+
const mockTags = [{ id: '1', name: 'Tag 1', slug: 'tag-1' }];
|
|
496
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
497
|
+
|
|
498
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
499
|
+
await tool.handler({ include: 'count.posts' });
|
|
500
|
+
|
|
501
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
502
|
+
include: 'count.posts',
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('should filter by name parameter', async () => {
|
|
507
|
+
const mockTags = [{ id: '1', name: 'Test Tag', slug: 'test-tag' }];
|
|
508
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
509
|
+
|
|
510
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
511
|
+
await tool.handler({ name: 'Test Tag' });
|
|
512
|
+
|
|
513
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
514
|
+
filter: "name:'Test Tag'",
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should filter by slug parameter', async () => {
|
|
519
|
+
const mockTags = [{ id: '1', name: 'Test Tag', slug: 'test-tag' }];
|
|
520
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
521
|
+
|
|
522
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
523
|
+
await tool.handler({ slug: 'test-tag' });
|
|
524
|
+
|
|
525
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
526
|
+
filter: "slug:'test-tag'",
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('should filter by visibility parameter', async () => {
|
|
531
|
+
const mockTags = [{ id: '1', name: 'Test Tag', slug: 'test-tag' }];
|
|
532
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
533
|
+
|
|
534
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
535
|
+
await tool.handler({ visibility: 'public' });
|
|
536
|
+
|
|
537
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
538
|
+
filter: "visibility:'public'",
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should escape single quotes in name parameter', async () => {
|
|
543
|
+
const mockTags = [{ id: '1', name: "O'Reilly", slug: 'oreilly' }];
|
|
544
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
545
|
+
|
|
546
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
547
|
+
await tool.handler({ name: "O'Reilly" });
|
|
548
|
+
|
|
549
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
550
|
+
filter: "name:'O''Reilly'",
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should escape single quotes in slug parameter', async () => {
|
|
555
|
+
const mockTags = [{ id: '1', name: 'Test', slug: "test'slug" }];
|
|
556
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
557
|
+
|
|
558
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
559
|
+
await tool.handler({ slug: "test'slug" });
|
|
560
|
+
|
|
561
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
562
|
+
filter: "slug:'test''slug'",
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('should combine multiple filter parameters', async () => {
|
|
567
|
+
const mockTags = [{ id: '1', name: 'News', slug: 'news' }];
|
|
568
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
569
|
+
|
|
570
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
571
|
+
await tool.handler({ name: 'News', visibility: 'public' });
|
|
572
|
+
|
|
573
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
574
|
+
filter: "name:'News'+visibility:'public'",
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('should combine individual filters with custom filter parameter', async () => {
|
|
579
|
+
const mockTags = [{ id: '1', name: 'News', slug: 'news' }];
|
|
580
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
581
|
+
|
|
582
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
583
|
+
await tool.handler({ name: 'News', filter: 'featured:true' });
|
|
584
|
+
|
|
585
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
586
|
+
filter: "name:'News'+featured:true",
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('should pass all parameters combined', async () => {
|
|
591
|
+
const mockTags = [{ id: '1', name: 'News', slug: 'news' }];
|
|
592
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
593
|
+
|
|
594
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
595
|
+
await tool.handler({
|
|
596
|
+
limit: 20,
|
|
597
|
+
page: 1,
|
|
598
|
+
order: 'name ASC',
|
|
599
|
+
include: 'count.posts',
|
|
600
|
+
name: 'News',
|
|
601
|
+
visibility: 'public',
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
expect(mockGetTags).toHaveBeenCalledWith({
|
|
605
|
+
limit: 20,
|
|
606
|
+
page: 1,
|
|
607
|
+
order: 'name ASC',
|
|
608
|
+
include: 'count.posts',
|
|
609
|
+
filter: "name:'News'+visibility:'public'",
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('should handle service errors', async () => {
|
|
614
|
+
mockGetTags.mockRejectedValue(new Error('Service error'));
|
|
615
|
+
|
|
616
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
617
|
+
const result = await tool.handler({});
|
|
618
|
+
|
|
619
|
+
expect(result.isError).toBe(true);
|
|
620
|
+
expect(result.content[0].text).toContain('Service error');
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('should return formatted JSON response', async () => {
|
|
624
|
+
const mockTags = [{ id: '1', name: 'Test Tag', slug: 'test-tag' }];
|
|
625
|
+
mockGetTags.mockResolvedValue(mockTags);
|
|
626
|
+
|
|
627
|
+
const tool = mockTools.get('ghost_get_tags');
|
|
628
|
+
const result = await tool.handler({});
|
|
629
|
+
|
|
630
|
+
expect(result.content).toBeDefined();
|
|
631
|
+
expect(result.content[0].type).toBe('text');
|
|
632
|
+
expect(result.content[0].text).toContain('"id": "1"');
|
|
633
|
+
expect(result.content[0].text).toContain('"name": "Test Tag"');
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
379
637
|
describe('mcp_server - ghost_get_post tool', () => {
|
|
380
638
|
beforeEach(async () => {
|
|
381
639
|
vi.clearAllMocks();
|
|
@@ -14,6 +14,16 @@ vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => {
|
|
|
14
14
|
mockTools.set(name, { name, description, schema, handler });
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
registerTool(name, options, handler) {
|
|
18
|
+
// Store in the same format for backward compatibility with tests
|
|
19
|
+
mockTools.set(name, {
|
|
20
|
+
name,
|
|
21
|
+
description: options.description,
|
|
22
|
+
schema: options.inputSchema,
|
|
23
|
+
handler,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
connect(_transport) {
|
|
18
28
|
return Promise.resolve();
|
|
19
29
|
}
|
|
@@ -104,12 +114,17 @@ vi.mock('axios', () => ({
|
|
|
104
114
|
// Mock fs
|
|
105
115
|
const mockUnlink = vi.fn((path, cb) => cb(null));
|
|
106
116
|
const mockCreateWriteStream = vi.fn();
|
|
107
|
-
vi.mock('fs', () =>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
vi.mock('fs', async (importOriginal) => {
|
|
118
|
+
const actual = await importOriginal();
|
|
119
|
+
return {
|
|
120
|
+
...actual,
|
|
121
|
+
default: {
|
|
122
|
+
...actual.default,
|
|
123
|
+
unlink: (...args) => mockUnlink(...args),
|
|
124
|
+
createWriteStream: (...args) => mockCreateWriteStream(...args),
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
});
|
|
113
128
|
|
|
114
129
|
// Mock os
|
|
115
130
|
vi.mock('os', () => ({
|
|
@@ -44,7 +44,7 @@ describe('tagController', () => {
|
|
|
44
44
|
|
|
45
45
|
await getTags(req, res, next);
|
|
46
46
|
|
|
47
|
-
expect(ghostService.getTags).toHaveBeenCalledWith(
|
|
47
|
+
expect(ghostService.getTags).toHaveBeenCalledWith({});
|
|
48
48
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
49
49
|
expect(res.json).toHaveBeenCalledWith(mockTags);
|
|
50
50
|
expect(next).not.toHaveBeenCalled();
|
|
@@ -60,7 +60,7 @@ describe('tagController', () => {
|
|
|
60
60
|
|
|
61
61
|
await getTags(req, res, next);
|
|
62
62
|
|
|
63
|
-
expect(ghostService.getTags).toHaveBeenCalledWith('Technology');
|
|
63
|
+
expect(ghostService.getTags).toHaveBeenCalledWith({ filter: "name:'Technology'" });
|
|
64
64
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
65
65
|
expect(res.json).toHaveBeenCalledWith(mockTags);
|
|
66
66
|
expect(next).not.toHaveBeenCalled();
|
|
@@ -76,11 +76,126 @@ describe('tagController', () => {
|
|
|
76
76
|
|
|
77
77
|
await getTags(req, res, next);
|
|
78
78
|
|
|
79
|
-
expect(ghostService.getTags).toHaveBeenCalledWith(
|
|
79
|
+
expect(ghostService.getTags).toHaveBeenCalledWith({});
|
|
80
80
|
expect(res.status).not.toHaveBeenCalled();
|
|
81
81
|
expect(res.json).not.toHaveBeenCalled();
|
|
82
82
|
expect(next).toHaveBeenCalledWith(mockError);
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
it('should return 400 when name contains invalid characters', async () => {
|
|
86
|
+
const req = createMockRequest({ query: { name: "'; DROP TABLE tags; --" } });
|
|
87
|
+
const res = createMockResponse();
|
|
88
|
+
const next = createMockNext();
|
|
89
|
+
|
|
90
|
+
await getTags(req, res, next);
|
|
91
|
+
|
|
92
|
+
expect(ghostService.getTags).not.toHaveBeenCalled();
|
|
93
|
+
expect(res.status).toHaveBeenCalledWith(400);
|
|
94
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
95
|
+
expect.objectContaining({
|
|
96
|
+
message: 'Invalid query parameters',
|
|
97
|
+
errors: expect.arrayContaining([
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
path: 'name',
|
|
100
|
+
message: expect.stringContaining('invalid characters'),
|
|
101
|
+
}),
|
|
102
|
+
]),
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
expect(next).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should pass through additional query parameters', async () => {
|
|
109
|
+
const mockTags = [{ id: '1', name: 'Tech', slug: 'tech' }];
|
|
110
|
+
ghostService.getTags.mockResolvedValue(mockTags);
|
|
111
|
+
|
|
112
|
+
const req = createMockRequest({
|
|
113
|
+
query: {
|
|
114
|
+
limit: '10',
|
|
115
|
+
order: 'name asc',
|
|
116
|
+
include: 'count.posts',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const res = createMockResponse();
|
|
120
|
+
const next = createMockNext();
|
|
121
|
+
|
|
122
|
+
await getTags(req, res, next);
|
|
123
|
+
|
|
124
|
+
expect(ghostService.getTags).toHaveBeenCalledWith({
|
|
125
|
+
limit: 10,
|
|
126
|
+
order: 'name asc',
|
|
127
|
+
include: 'count.posts',
|
|
128
|
+
});
|
|
129
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should return 400 when both name and filter parameters are provided', async () => {
|
|
133
|
+
const req = createMockRequest({
|
|
134
|
+
query: {
|
|
135
|
+
name: 'Technology',
|
|
136
|
+
filter: 'slug:tech',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const res = createMockResponse();
|
|
140
|
+
const next = createMockNext();
|
|
141
|
+
|
|
142
|
+
await getTags(req, res, next);
|
|
143
|
+
|
|
144
|
+
expect(ghostService.getTags).not.toHaveBeenCalled();
|
|
145
|
+
expect(res.status).toHaveBeenCalledWith(400);
|
|
146
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
147
|
+
expect.objectContaining({
|
|
148
|
+
message: 'Invalid query parameters',
|
|
149
|
+
errors: expect.arrayContaining([
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
path: 'filter',
|
|
152
|
+
message: expect.stringContaining('Cannot specify both'),
|
|
153
|
+
}),
|
|
154
|
+
]),
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
expect(next).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should return 400 for NQL injection attempts in name', async () => {
|
|
161
|
+
const req = createMockRequest({ query: { name: 'test]+[slug:other' } });
|
|
162
|
+
const res = createMockResponse();
|
|
163
|
+
const next = createMockNext();
|
|
164
|
+
|
|
165
|
+
await getTags(req, res, next);
|
|
166
|
+
|
|
167
|
+
expect(ghostService.getTags).not.toHaveBeenCalled();
|
|
168
|
+
expect(res.status).toHaveBeenCalledWith(400);
|
|
169
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
170
|
+
expect.objectContaining({
|
|
171
|
+
message: 'Invalid query parameters',
|
|
172
|
+
errors: expect.arrayContaining([
|
|
173
|
+
expect.objectContaining({
|
|
174
|
+
path: 'name',
|
|
175
|
+
message: expect.stringContaining('invalid characters'),
|
|
176
|
+
}),
|
|
177
|
+
]),
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
expect(next).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should return 400 for NQL operator injection in name', async () => {
|
|
184
|
+
const req = createMockRequest({ query: { name: 'name+slug:test' } });
|
|
185
|
+
const res = createMockResponse();
|
|
186
|
+
const next = createMockNext();
|
|
187
|
+
|
|
188
|
+
await getTags(req, res, next);
|
|
189
|
+
|
|
190
|
+
expect(ghostService.getTags).not.toHaveBeenCalled();
|
|
191
|
+
expect(res.status).toHaveBeenCalledWith(400);
|
|
192
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
193
|
+
expect.objectContaining({
|
|
194
|
+
message: 'Invalid query parameters',
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
expect(next).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
84
199
|
});
|
|
85
200
|
|
|
86
201
|
describe('createTag', () => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getTags as getGhostTags, createTag as createGhostTag } from '../services/ghostService.js';
|
|
2
2
|
import { createContextLogger } from '../utils/logger.js';
|
|
3
|
+
import { tagQuerySchema } from '../schemas/tagSchemas.js';
|
|
4
|
+
import { ZodError } from 'zod';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Controller to handle fetching tags.
|
|
@@ -9,24 +11,48 @@ const getTags = async (req, res, next) => {
|
|
|
9
11
|
const logger = createContextLogger('tag-controller');
|
|
10
12
|
|
|
11
13
|
try {
|
|
12
|
-
|
|
14
|
+
// Validate query parameters using Zod schema
|
|
15
|
+
const validatedQuery = tagQuerySchema.parse(req.query);
|
|
16
|
+
|
|
17
|
+
// Build options object
|
|
18
|
+
const options = {};
|
|
19
|
+
|
|
20
|
+
// Handle legacy name parameter by converting to filter
|
|
21
|
+
if (validatedQuery.name) {
|
|
22
|
+
// Escape single quotes and backslashes to prevent injection
|
|
23
|
+
const safeName = validatedQuery.name.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
24
|
+
options.filter = `name:'${safeName}'`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Add other query parameters
|
|
28
|
+
if (validatedQuery.limit) options.limit = validatedQuery.limit;
|
|
29
|
+
if (validatedQuery.filter) options.filter = validatedQuery.filter;
|
|
30
|
+
if (validatedQuery.order) options.order = validatedQuery.order;
|
|
31
|
+
if (validatedQuery.include) options.include = validatedQuery.include;
|
|
32
|
+
|
|
13
33
|
logger.info('Fetching tags', {
|
|
14
|
-
|
|
15
|
-
filterName: name,
|
|
34
|
+
options,
|
|
16
35
|
});
|
|
17
36
|
|
|
18
|
-
const tags = await getGhostTags(
|
|
37
|
+
const tags = await getGhostTags(options);
|
|
19
38
|
|
|
20
39
|
logger.info('Tags retrieved successfully', {
|
|
21
40
|
count: tags.length,
|
|
22
|
-
filtered: !!name,
|
|
23
41
|
});
|
|
24
42
|
|
|
25
43
|
res.status(200).json(tags);
|
|
26
44
|
} catch (error) {
|
|
45
|
+
if (error instanceof ZodError) {
|
|
46
|
+
logger.warn('Invalid query parameters', { errors: error.errors });
|
|
47
|
+
return res.status(400).json({
|
|
48
|
+
message: 'Invalid query parameters',
|
|
49
|
+
errors: error.errors.map((e) => ({ path: e.path.join('.'), message: e.message })),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
27
53
|
logger.error('Get tags failed', {
|
|
28
54
|
error: error.message,
|
|
29
|
-
|
|
55
|
+
query: req.query,
|
|
30
56
|
});
|
|
31
57
|
next(error);
|
|
32
58
|
}
|