@adsim/wordpress-mcp-server 4.6.0 → 5.1.0
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/.env.example +18 -0
- package/README.md +851 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +353 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
|
@@ -80,7 +80,7 @@ describe('wp_site_info', () => {
|
|
|
80
80
|
|
|
81
81
|
// Server info
|
|
82
82
|
expect(data.server.mcp_version).toBeDefined();
|
|
83
|
-
expect(data.server.tools_total).toBe(
|
|
83
|
+
expect(data.server.tools_total).toBe(175);
|
|
84
84
|
expect(typeof data.server.tools_exposed).toBe('number');
|
|
85
85
|
expect(Array.isArray(data.server.filtered_out)).toBe(true);
|
|
86
86
|
});
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
import { handleToolCall } from '../../../index.js';
|
|
7
|
+
import { mockSuccess, mockError, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Helper: mock two sequential fetch calls
|
|
14
|
+
function mockTwoSuccess(data1, data2) {
|
|
15
|
+
fetch
|
|
16
|
+
.mockImplementationOnce(() => Promise.resolve({
|
|
17
|
+
ok: true, status: 200,
|
|
18
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
19
|
+
json: () => Promise.resolve(data1),
|
|
20
|
+
text: () => Promise.resolve(JSON.stringify(data1))
|
|
21
|
+
}))
|
|
22
|
+
.mockImplementationOnce(() => Promise.resolve({
|
|
23
|
+
ok: true, status: 200,
|
|
24
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
25
|
+
json: () => Promise.resolve(data2),
|
|
26
|
+
text: () => Promise.resolve(JSON.stringify(data2))
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ════════════════════════════════════════════════════════════
|
|
31
|
+
// wp_get_user
|
|
32
|
+
// ════════════════════════════════════════════════════════════
|
|
33
|
+
|
|
34
|
+
describe('wp_get_user', () => {
|
|
35
|
+
let consoleSpy;
|
|
36
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
37
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
38
|
+
|
|
39
|
+
it('returns full user profile', async () => {
|
|
40
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
41
|
+
id: 1, username: 'admin', name: 'Admin', slug: 'admin', email: 'admin@example.com',
|
|
42
|
+
roles: ['administrator'], first_name: 'Site', last_name: 'Admin', url: 'https://example.com',
|
|
43
|
+
description: 'The admin', link: 'https://example.com/author/admin', registered_date: '2023-01-01T00:00:00',
|
|
44
|
+
locale: 'en_US', nickname: 'admin', avatar_urls: { '96': 'https://example.com/avatar.jpg' }, meta: {}
|
|
45
|
+
}));
|
|
46
|
+
const result = await call('wp_get_user', { id: 1 });
|
|
47
|
+
const data = parseResult(result);
|
|
48
|
+
expect(data.id).toBe(1);
|
|
49
|
+
expect(data.username).toBe('admin');
|
|
50
|
+
expect(data.email).toBe('admin@example.com');
|
|
51
|
+
expect(data.roles).toContain('administrator');
|
|
52
|
+
expect(data.first_name).toBe('Site');
|
|
53
|
+
expect(data.registered_date).toBe('2023-01-01T00:00:00');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns error on 404', async () => {
|
|
57
|
+
fetch.mockResolvedValue(mockError(404));
|
|
58
|
+
const result = await call('wp_get_user', { id: 9999 });
|
|
59
|
+
expect(result.isError).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('logs audit entry', async () => {
|
|
63
|
+
fetch.mockResolvedValue(mockSuccess({ id: 1, name: 'Admin', slug: 'admin', roles: [] }));
|
|
64
|
+
await call('wp_get_user', { id: 1 });
|
|
65
|
+
const logs = getAuditLogs();
|
|
66
|
+
const entry = logs.find(l => l.tool === 'wp_get_user');
|
|
67
|
+
expect(entry).toBeDefined();
|
|
68
|
+
expect(entry.target).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ════════════════════════════════════════════════════════════
|
|
73
|
+
// wp_create_user
|
|
74
|
+
// ════════════════════════════════════════════════════════════
|
|
75
|
+
|
|
76
|
+
describe('wp_create_user', () => {
|
|
77
|
+
let consoleSpy;
|
|
78
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
79
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
80
|
+
|
|
81
|
+
it('creates a user when confirm=true', async () => {
|
|
82
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
83
|
+
id: 5, username: 'newuser', slug: 'newuser', email: 'new@example.com',
|
|
84
|
+
name: 'New User', roles: ['subscriber']
|
|
85
|
+
}));
|
|
86
|
+
const result = await call('wp_create_user', {
|
|
87
|
+
username: 'newuser', email: 'new@example.com', password: 'Str0ngP@ss!', confirm: true
|
|
88
|
+
});
|
|
89
|
+
const data = parseResult(result);
|
|
90
|
+
expect(data.success).toBe(true);
|
|
91
|
+
expect(data.user.id).toBe(5);
|
|
92
|
+
expect(data.user.username).toBe('newuser');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('rejects when confirm is not true', async () => {
|
|
96
|
+
const result = await call('wp_create_user', {
|
|
97
|
+
username: 'test', email: 'test@example.com', password: 'pass', confirm: false
|
|
98
|
+
});
|
|
99
|
+
expect(result.isError).toBe(true);
|
|
100
|
+
expect(result.content[0].text).toContain('confirm=true');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects when confirm is missing', async () => {
|
|
104
|
+
const result = await call('wp_create_user', {
|
|
105
|
+
username: 'test', email: 'test@example.com', password: 'pass'
|
|
106
|
+
});
|
|
107
|
+
expect(result.isError).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('passes optional fields', async () => {
|
|
111
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
112
|
+
id: 6, username: 'jane', slug: 'jane', email: 'jane@example.com',
|
|
113
|
+
name: 'Jane Doe', roles: ['editor']
|
|
114
|
+
}));
|
|
115
|
+
await call('wp_create_user', {
|
|
116
|
+
username: 'jane', email: 'jane@example.com', password: 'P@ss123!',
|
|
117
|
+
role: 'editor', first_name: 'Jane', last_name: 'Doe', confirm: true
|
|
118
|
+
});
|
|
119
|
+
const [, opts] = fetch.mock.calls[0];
|
|
120
|
+
const body = JSON.parse(opts.body);
|
|
121
|
+
expect(body.roles).toEqual(['editor']);
|
|
122
|
+
expect(body.first_name).toBe('Jane');
|
|
123
|
+
expect(body.last_name).toBe('Doe');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('is blocked by WP_READ_ONLY', async () => {
|
|
127
|
+
process.env.WP_READ_ONLY = 'true';
|
|
128
|
+
try {
|
|
129
|
+
const result = await call('wp_create_user', {
|
|
130
|
+
username: 'test', email: 'test@example.com', password: 'pass', confirm: true
|
|
131
|
+
});
|
|
132
|
+
expect(result.isError).toBe(true);
|
|
133
|
+
expect(result.content[0].text).toContain('READ-ONLY');
|
|
134
|
+
} finally {
|
|
135
|
+
delete process.env.WP_READ_ONLY;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ════════════════════════════════════════════════════════════
|
|
141
|
+
// wp_update_user
|
|
142
|
+
// ════════════════════════════════════════════════════════════
|
|
143
|
+
|
|
144
|
+
describe('wp_update_user', () => {
|
|
145
|
+
let consoleSpy;
|
|
146
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
147
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
148
|
+
|
|
149
|
+
it('updates user fields', async () => {
|
|
150
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
151
|
+
id: 2, name: 'Updated Name', email: 'updated@example.com', roles: ['editor'], slug: 'updated'
|
|
152
|
+
}));
|
|
153
|
+
const result = await call('wp_update_user', { id: 2, display_name: 'Updated Name', role: 'editor' });
|
|
154
|
+
const data = parseResult(result);
|
|
155
|
+
expect(data.success).toBe(true);
|
|
156
|
+
expect(data.user.name).toBe('Updated Name');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('sends correct payload', async () => {
|
|
160
|
+
fetch.mockResolvedValue(mockSuccess({ id: 2, name: 'X', email: 'x@x.com', roles: ['author'], slug: 'x' }));
|
|
161
|
+
await call('wp_update_user', { id: 2, email: 'new@example.com', description: 'A bio', meta: { custom: 'value' } });
|
|
162
|
+
const [, opts] = fetch.mock.calls[0];
|
|
163
|
+
const body = JSON.parse(opts.body);
|
|
164
|
+
expect(body.email).toBe('new@example.com');
|
|
165
|
+
expect(body.description).toBe('A bio');
|
|
166
|
+
expect(body.meta).toEqual({ custom: 'value' });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('is blocked by WP_READ_ONLY', async () => {
|
|
170
|
+
process.env.WP_READ_ONLY = 'true';
|
|
171
|
+
try {
|
|
172
|
+
const result = await call('wp_update_user', { id: 2, display_name: 'X' });
|
|
173
|
+
expect(result.isError).toBe(true);
|
|
174
|
+
expect(result.content[0].text).toContain('READ-ONLY');
|
|
175
|
+
} finally {
|
|
176
|
+
delete process.env.WP_READ_ONLY;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ════════════════════════════════════════════════════════════
|
|
182
|
+
// wp_delete_user
|
|
183
|
+
// ════════════════════════════════════════════════════════════
|
|
184
|
+
|
|
185
|
+
describe('wp_delete_user', () => {
|
|
186
|
+
let consoleSpy;
|
|
187
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
188
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
189
|
+
|
|
190
|
+
it('deletes user when confirm=true and reassign provided', async () => {
|
|
191
|
+
fetch.mockResolvedValue(mockSuccess({ deleted: true }));
|
|
192
|
+
const result = await call('wp_delete_user', { id: 5, reassign: 1, confirm: true });
|
|
193
|
+
const data = parseResult(result);
|
|
194
|
+
expect(data.success).toBe(true);
|
|
195
|
+
expect(data.message).toContain('reassigned to user 1');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('rejects when confirm is not true', async () => {
|
|
199
|
+
const result = await call('wp_delete_user', { id: 5, reassign: 1, confirm: false });
|
|
200
|
+
expect(result.isError).toBe(true);
|
|
201
|
+
expect(result.content[0].text).toContain('confirm=true');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('passes force and reassign in URL', async () => {
|
|
205
|
+
fetch.mockResolvedValue(mockSuccess({ deleted: true }));
|
|
206
|
+
await call('wp_delete_user', { id: 5, reassign: 1, confirm: true });
|
|
207
|
+
const [url] = fetch.mock.calls[0];
|
|
208
|
+
expect(url).toContain('/users/5?force=true&reassign=1');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('is blocked by WP_READ_ONLY', async () => {
|
|
212
|
+
process.env.WP_READ_ONLY = 'true';
|
|
213
|
+
try {
|
|
214
|
+
const result = await call('wp_delete_user', { id: 5, reassign: 1, confirm: true });
|
|
215
|
+
expect(result.isError).toBe(true);
|
|
216
|
+
expect(result.content[0].text).toContain('READ-ONLY');
|
|
217
|
+
} finally {
|
|
218
|
+
delete process.env.WP_READ_ONLY;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('is blocked by WP_DISABLE_DELETE', async () => {
|
|
223
|
+
process.env.WP_DISABLE_DELETE = 'true';
|
|
224
|
+
try {
|
|
225
|
+
const result = await call('wp_delete_user', { id: 5, reassign: 1, confirm: true });
|
|
226
|
+
expect(result.isError).toBe(true);
|
|
227
|
+
expect(result.content[0].text).toContain('DISABLE_DELETE');
|
|
228
|
+
} finally {
|
|
229
|
+
delete process.env.WP_DISABLE_DELETE;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ════════════════════════════════════════════════════════════
|
|
235
|
+
// wp_list_user_roles
|
|
236
|
+
// ════════════════════════════════════════════════════════════
|
|
237
|
+
|
|
238
|
+
describe('wp_list_user_roles', () => {
|
|
239
|
+
let consoleSpy;
|
|
240
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
241
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
242
|
+
|
|
243
|
+
it('returns roles as array', async () => {
|
|
244
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
245
|
+
{ slug: 'administrator', name: 'Administrator', capabilities: { manage_options: true, edit_posts: true } },
|
|
246
|
+
{ slug: 'editor', name: 'Editor', capabilities: { edit_posts: true, edit_others_posts: true } }
|
|
247
|
+
]));
|
|
248
|
+
const result = await call('wp_list_user_roles');
|
|
249
|
+
const data = parseResult(result);
|
|
250
|
+
expect(data.total).toBe(2);
|
|
251
|
+
expect(data.roles[0].slug).toBe('administrator');
|
|
252
|
+
expect(data.roles[0].capabilities.manage_options).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('handles object format', async () => {
|
|
256
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
257
|
+
administrator: { name: 'Administrator', capabilities: { manage_options: true } },
|
|
258
|
+
subscriber: { name: 'Subscriber', capabilities: { read: true } }
|
|
259
|
+
}));
|
|
260
|
+
const result = await call('wp_list_user_roles');
|
|
261
|
+
const data = parseResult(result);
|
|
262
|
+
expect(data.total).toBe(2);
|
|
263
|
+
expect(data.roles.find(r => r.slug === 'administrator')).toBeDefined();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ════════════════════════════════════════════════════════════
|
|
268
|
+
// wp_get_user_capabilities
|
|
269
|
+
// ════════════════════════════════════════════════════════════
|
|
270
|
+
|
|
271
|
+
describe('wp_get_user_capabilities', () => {
|
|
272
|
+
let consoleSpy;
|
|
273
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
274
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
275
|
+
|
|
276
|
+
it('returns active capabilities', async () => {
|
|
277
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
278
|
+
id: 1, name: 'Admin', roles: ['administrator'],
|
|
279
|
+
capabilities: { manage_options: true, edit_posts: true, read: true, delete_posts: false }
|
|
280
|
+
}));
|
|
281
|
+
const result = await call('wp_get_user_capabilities', { id: 1 });
|
|
282
|
+
const data = parseResult(result);
|
|
283
|
+
expect(data.id).toBe(1);
|
|
284
|
+
expect(data.capabilities).toContain('manage_options');
|
|
285
|
+
expect(data.capabilities).toContain('edit_posts');
|
|
286
|
+
expect(data.capabilities).not.toContain('delete_posts');
|
|
287
|
+
expect(data.capabilities_count).toBe(3);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ════════════════════════════════════════════════════════════
|
|
292
|
+
// wp_reset_user_password
|
|
293
|
+
// ════════════════════════════════════════════════════════════
|
|
294
|
+
|
|
295
|
+
describe('wp_reset_user_password', () => {
|
|
296
|
+
let consoleSpy;
|
|
297
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
298
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
299
|
+
|
|
300
|
+
it('sends password reset email', async () => {
|
|
301
|
+
mockTwoSuccess(
|
|
302
|
+
{ id: 3, name: 'User', email: 'user@example.com' },
|
|
303
|
+
{ success: true, user_id: 3, message: 'Password reset email sent.' }
|
|
304
|
+
);
|
|
305
|
+
const result = await call('wp_reset_user_password', { id: 3 });
|
|
306
|
+
const data = parseResult(result);
|
|
307
|
+
expect(data.success).toBe(true);
|
|
308
|
+
expect(data.user.email).toContain('***'); // Masked email
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('is blocked by WP_READ_ONLY', async () => {
|
|
312
|
+
process.env.WP_READ_ONLY = 'true';
|
|
313
|
+
try {
|
|
314
|
+
const result = await call('wp_reset_user_password', { id: 3 });
|
|
315
|
+
expect(result.isError).toBe(true);
|
|
316
|
+
expect(result.content[0].text).toContain('READ-ONLY');
|
|
317
|
+
} finally {
|
|
318
|
+
delete process.env.WP_READ_ONLY;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('calls mcp-diagnostics endpoint', async () => {
|
|
323
|
+
mockTwoSuccess(
|
|
324
|
+
{ id: 3, name: 'User', email: 'user@example.com' },
|
|
325
|
+
{ success: true }
|
|
326
|
+
);
|
|
327
|
+
await call('wp_reset_user_password', { id: 3 });
|
|
328
|
+
const secondUrl = fetch.mock.calls[1][0];
|
|
329
|
+
expect(secondUrl).toContain('/wp-json/mcp-diagnostics/v1/password-reset');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ════════════════════════════════════════════════════════════
|
|
334
|
+
// wp_list_user_application_passwords
|
|
335
|
+
// ════════════════════════════════════════════════════════════
|
|
336
|
+
|
|
337
|
+
describe('wp_list_user_application_passwords', () => {
|
|
338
|
+
let consoleSpy;
|
|
339
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
340
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
341
|
+
|
|
342
|
+
it('returns application passwords', async () => {
|
|
343
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
344
|
+
{ uuid: 'abc-123', name: 'MCP Server', created: '2024-01-01', last_used: '2024-06-01', last_ip: '1.2.3.4' },
|
|
345
|
+
{ uuid: 'def-456', name: 'Mobile App', created: '2024-03-01', last_used: null, last_ip: null }
|
|
346
|
+
]));
|
|
347
|
+
const result = await call('wp_list_user_application_passwords', { id: 1 });
|
|
348
|
+
const data = parseResult(result);
|
|
349
|
+
expect(data.user_id).toBe(1);
|
|
350
|
+
expect(data.total).toBe(2);
|
|
351
|
+
expect(data.application_passwords[0].uuid).toBe('abc-123');
|
|
352
|
+
expect(data.application_passwords[0].name).toBe('MCP Server');
|
|
353
|
+
expect(data.application_passwords[1].last_used).toBeNull();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('calls correct endpoint', async () => {
|
|
357
|
+
fetch.mockResolvedValue(mockSuccess([]));
|
|
358
|
+
await call('wp_list_user_application_passwords', { id: 1 });
|
|
359
|
+
const [url] = fetch.mock.calls[0];
|
|
360
|
+
expect(url).toContain('/users/1/application-passwords');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ════════════════════════════════════════════════════════════
|
|
365
|
+
// wp_revoke_application_password
|
|
366
|
+
// ════════════════════════════════════════════════════════════
|
|
367
|
+
|
|
368
|
+
describe('wp_revoke_application_password', () => {
|
|
369
|
+
let consoleSpy;
|
|
370
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
371
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
372
|
+
|
|
373
|
+
it('revokes an application password', async () => {
|
|
374
|
+
fetch.mockResolvedValue(mockSuccess({ deleted: true }));
|
|
375
|
+
const result = await call('wp_revoke_application_password', { id: 1, uuid: 'abc-123' });
|
|
376
|
+
const data = parseResult(result);
|
|
377
|
+
expect(data.success).toBe(true);
|
|
378
|
+
expect(data.message).toContain('abc-123');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('calls correct DELETE endpoint', async () => {
|
|
382
|
+
fetch.mockResolvedValue(mockSuccess({ deleted: true }));
|
|
383
|
+
await call('wp_revoke_application_password', { id: 1, uuid: 'abc-123' });
|
|
384
|
+
const [url, opts] = fetch.mock.calls[0];
|
|
385
|
+
expect(url).toContain('/users/1/application-passwords/abc-123');
|
|
386
|
+
expect(opts.method).toBe('DELETE');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('is blocked by WP_READ_ONLY', async () => {
|
|
390
|
+
process.env.WP_READ_ONLY = 'true';
|
|
391
|
+
try {
|
|
392
|
+
const result = await call('wp_revoke_application_password', { id: 1, uuid: 'abc-123' });
|
|
393
|
+
expect(result.isError).toBe(true);
|
|
394
|
+
expect(result.content[0].text).toContain('READ-ONLY');
|
|
395
|
+
} finally {
|
|
396
|
+
delete process.env.WP_READ_ONLY;
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
import { handleToolCall, _testSetTarget } from '../../../index.js';
|
|
7
|
+
import { makeRequest, mockSuccess, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let consoleSpy;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
fetch.mockReset();
|
|
17
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
18
|
+
_testSetTarget('site1', { url: 'https://example.com', auth: 'Basic dGVzdDp0ZXN0' });
|
|
19
|
+
delete process.env.WP_VALIDATE_BLOCKS;
|
|
20
|
+
delete process.env.WP_READ_ONLY;
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
consoleSpy.mockRestore();
|
|
24
|
+
delete process.env.WP_VALIDATE_BLOCKS;
|
|
25
|
+
delete process.env.WP_READ_ONLY;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// =========================================================================
|
|
29
|
+
// 1. Valid HTML → valid: true, errors: []
|
|
30
|
+
// =========================================================================
|
|
31
|
+
describe('wp_validate_block_structure', () => {
|
|
32
|
+
it('returns valid for well-formed Gutenberg blocks', async () => {
|
|
33
|
+
const content = [
|
|
34
|
+
'<!-- wp:paragraph -->',
|
|
35
|
+
'<p>Hello world</p>',
|
|
36
|
+
'<!-- /wp:paragraph -->',
|
|
37
|
+
'',
|
|
38
|
+
'<!-- wp:heading {"level":2} -->',
|
|
39
|
+
'<h2>Title</h2>',
|
|
40
|
+
'<!-- /wp:heading -->'
|
|
41
|
+
].join('\n');
|
|
42
|
+
|
|
43
|
+
const res = await call('wp_validate_block_structure', { content });
|
|
44
|
+
const data = parseResult(res);
|
|
45
|
+
expect(data.valid).toBe(true);
|
|
46
|
+
expect(data.errors).toHaveLength(0);
|
|
47
|
+
expect(data.blocks_found).toEqual(
|
|
48
|
+
expect.arrayContaining([
|
|
49
|
+
{ name: 'core/paragraph', count: 1 },
|
|
50
|
+
{ name: 'core/heading', count: 1 }
|
|
51
|
+
])
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// =========================================================================
|
|
56
|
+
// 2. Unclosed block → error detected
|
|
57
|
+
// =========================================================================
|
|
58
|
+
it('detects unclosed block comment', async () => {
|
|
59
|
+
const content = [
|
|
60
|
+
'<!-- wp:paragraph -->',
|
|
61
|
+
'<p>No closing comment</p>'
|
|
62
|
+
].join('\n');
|
|
63
|
+
|
|
64
|
+
const res = await call('wp_validate_block_structure', { content });
|
|
65
|
+
const data = parseResult(res);
|
|
66
|
+
expect(data.valid).toBe(false);
|
|
67
|
+
expect(data.errors.some(e => e.type === 'unclosed_block')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// =========================================================================
|
|
71
|
+
// 3. Malformed JSON in comment → error detected
|
|
72
|
+
// =========================================================================
|
|
73
|
+
it('detects malformed JSON in block attributes', async () => {
|
|
74
|
+
const content = [
|
|
75
|
+
'<!-- wp:heading {level:2} -->',
|
|
76
|
+
'<h2>Bad JSON</h2>',
|
|
77
|
+
'<!-- /wp:heading -->'
|
|
78
|
+
].join('\n');
|
|
79
|
+
|
|
80
|
+
const res = await call('wp_validate_block_structure', { content });
|
|
81
|
+
const data = parseResult(res);
|
|
82
|
+
expect(data.valid).toBe(false);
|
|
83
|
+
expect(data.errors.some(e => e.type === 'malformed_json')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// =========================================================================
|
|
87
|
+
// 4. Invalid nesting → error detected
|
|
88
|
+
// =========================================================================
|
|
89
|
+
it('detects invalid nesting (paragraph inside paragraph)', async () => {
|
|
90
|
+
const content = [
|
|
91
|
+
'<!-- wp:paragraph -->',
|
|
92
|
+
'<p>Outer</p>',
|
|
93
|
+
'<!-- wp:paragraph -->',
|
|
94
|
+
'<p>Inner — invalid</p>',
|
|
95
|
+
'<!-- /wp:paragraph -->',
|
|
96
|
+
'<!-- /wp:paragraph -->'
|
|
97
|
+
].join('\n');
|
|
98
|
+
|
|
99
|
+
const res = await call('wp_validate_block_structure', { content });
|
|
100
|
+
const data = parseResult(res);
|
|
101
|
+
expect(data.valid).toBe(false);
|
|
102
|
+
expect(data.errors.some(e => e.type === 'invalid_nesting')).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// =========================================================================
|
|
106
|
+
// 5. strict: false → no error on missing attributes
|
|
107
|
+
// =========================================================================
|
|
108
|
+
it('does not error on missing attributes when strict=false', async () => {
|
|
109
|
+
const content = [
|
|
110
|
+
'<!-- wp:heading -->',
|
|
111
|
+
'<h2>No attributes</h2>',
|
|
112
|
+
'<!-- /wp:heading -->'
|
|
113
|
+
].join('\n');
|
|
114
|
+
|
|
115
|
+
const res = await call('wp_validate_block_structure', { content, strict: false });
|
|
116
|
+
const data = parseResult(res);
|
|
117
|
+
expect(data.valid).toBe(true);
|
|
118
|
+
expect(data.errors.filter(e => e.type === 'missing_attributes')).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// =========================================================================
|
|
122
|
+
// 6. strict: true → error on missing attributes
|
|
123
|
+
// =========================================================================
|
|
124
|
+
it('errors on missing attributes when strict=true', async () => {
|
|
125
|
+
const content = [
|
|
126
|
+
'<!-- wp:heading -->',
|
|
127
|
+
'<h2>No attributes</h2>',
|
|
128
|
+
'<!-- /wp:heading -->'
|
|
129
|
+
].join('\n');
|
|
130
|
+
|
|
131
|
+
const res = await call('wp_validate_block_structure', { content, strict: true });
|
|
132
|
+
const data = parseResult(res);
|
|
133
|
+
expect(data.valid).toBe(false);
|
|
134
|
+
expect(data.errors.some(e => e.type === 'missing_attributes')).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// =========================================================================
|
|
139
|
+
// 7-9. WP_VALIDATE_BLOCKS guard on wp_update_post
|
|
140
|
+
// =========================================================================
|
|
141
|
+
describe('WP_VALIDATE_BLOCKS guard', () => {
|
|
142
|
+
// 7. WP_VALIDATE_BLOCKS=true → blocks update if invalid content
|
|
143
|
+
it('blocks wp_update_post with invalid content when WP_VALIDATE_BLOCKS=true', async () => {
|
|
144
|
+
process.env.WP_VALIDATE_BLOCKS = 'true';
|
|
145
|
+
const invalidContent = '<!-- wp:paragraph -->\n<p>Unclosed block</p>';
|
|
146
|
+
|
|
147
|
+
const res = await call('wp_update_post', { id: 1, content: invalidContent });
|
|
148
|
+
expect(res.isError).toBe(true);
|
|
149
|
+
const data = JSON.parse(res.content[0].text);
|
|
150
|
+
expect(data.status).toBe('blocked');
|
|
151
|
+
expect(data.reason).toContain('WP_VALIDATE_BLOCKS');
|
|
152
|
+
expect(data.errors.length).toBeGreaterThan(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// 8. WP_VALIDATE_BLOCKS=true → allows update if valid content
|
|
156
|
+
it('allows wp_update_post with valid content when WP_VALIDATE_BLOCKS=true', async () => {
|
|
157
|
+
process.env.WP_VALIDATE_BLOCKS = 'true';
|
|
158
|
+
const validContent = '<!-- wp:paragraph -->\n<p>Valid</p>\n<!-- /wp:paragraph -->';
|
|
159
|
+
|
|
160
|
+
// POST /posts/1 → updated
|
|
161
|
+
mockSuccess({
|
|
162
|
+
id: 1, title: { rendered: 'Test' }, status: 'publish',
|
|
163
|
+
link: 'https://example.com/test/', modified: '2025-06-16'
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const res = await call('wp_update_post', { id: 1, content: validContent });
|
|
167
|
+
const data = parseResult(res);
|
|
168
|
+
expect(data.success).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// 9. WP_VALIDATE_BLOCKS not set → no interception
|
|
172
|
+
it('does not intercept wp_update_post when WP_VALIDATE_BLOCKS is not set', async () => {
|
|
173
|
+
// No WP_VALIDATE_BLOCKS env var
|
|
174
|
+
const invalidContent = '<!-- wp:paragraph -->\n<p>Unclosed</p>';
|
|
175
|
+
|
|
176
|
+
// POST /posts/1 → updated normally despite invalid blocks
|
|
177
|
+
mockSuccess({
|
|
178
|
+
id: 1, title: { rendered: 'Test' }, status: 'publish',
|
|
179
|
+
link: 'https://example.com/test/', modified: '2025-06-16'
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const res = await call('wp_update_post', { id: 1, content: invalidContent });
|
|
183
|
+
const data = parseResult(res);
|
|
184
|
+
expect(data.success).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
});
|