@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
|
@@ -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
|
+
{ title: 'Updated Title', updated_at: '2024-01-01T00:00:00.000Z' },
|
|
99
|
+
{ id: postId }
|
|
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
|
+
{ ...updateData, updated_at: existingPost.updated_at },
|
|
177
|
+
{ id: postId }
|
|
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
|
+
});
|