@adsim/wordpress-mcp-server 4.5.1 → 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.
Files changed (61) hide show
  1. package/.env.example +18 -0
  2. package/README.md +857 -447
  3. package/companion/mcp-diagnostics.php +1184 -0
  4. package/dxt/manifest.json +718 -90
  5. package/index.js +188 -4747
  6. package/package.json +14 -6
  7. package/src/data/plugin-performance-data.json +59 -0
  8. package/src/plugins/IPluginAdapter.js +95 -0
  9. package/src/plugins/adapters/acf/acfAdapter.js +181 -0
  10. package/src/plugins/adapters/elementor/elementorAdapter.js +176 -0
  11. package/src/plugins/contextGuard.js +57 -0
  12. package/src/plugins/registry.js +94 -0
  13. package/src/shared/api.js +79 -0
  14. package/src/shared/audit.js +39 -0
  15. package/src/shared/context.js +15 -0
  16. package/src/shared/governance.js +98 -0
  17. package/src/shared/utils.js +148 -0
  18. package/src/tools/comments.js +50 -0
  19. package/src/tools/content.js +353 -0
  20. package/src/tools/core.js +114 -0
  21. package/src/tools/editorial.js +634 -0
  22. package/src/tools/fse.js +370 -0
  23. package/src/tools/health.js +160 -0
  24. package/src/tools/index.js +96 -0
  25. package/src/tools/intelligence.js +2082 -0
  26. package/src/tools/links.js +118 -0
  27. package/src/tools/media.js +71 -0
  28. package/src/tools/performance.js +219 -0
  29. package/src/tools/plugins.js +368 -0
  30. package/src/tools/schema.js +417 -0
  31. package/src/tools/security.js +590 -0
  32. package/src/tools/seo.js +1633 -0
  33. package/src/tools/taxonomy.js +115 -0
  34. package/src/tools/users.js +188 -0
  35. package/src/tools/woocommerce.js +1008 -0
  36. package/src/tools/workflow.js +409 -0
  37. package/src/transport/http.js +39 -0
  38. package/tests/unit/helpers/pagination.test.js +43 -0
  39. package/tests/unit/pluginLayer.test.js +151 -0
  40. package/tests/unit/plugins/acf/acfAdapter.test.js +205 -0
  41. package/tests/unit/plugins/acf/acfAdapter.write.test.js +157 -0
  42. package/tests/unit/plugins/contextGuard.test.js +51 -0
  43. package/tests/unit/plugins/elementor/elementorAdapter.test.js +206 -0
  44. package/tests/unit/plugins/iPluginAdapter.test.js +34 -0
  45. package/tests/unit/plugins/registry.test.js +84 -0
  46. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  47. package/tests/unit/tools/diagnostics.test.js +397 -0
  48. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  49. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  50. package/tests/unit/tools/fse.test.js +548 -0
  51. package/tests/unit/tools/multilingual.test.js +653 -0
  52. package/tests/unit/tools/performance.test.js +351 -0
  53. package/tests/unit/tools/runWorkflow.test.js +150 -0
  54. package/tests/unit/tools/schema.test.js +477 -0
  55. package/tests/unit/tools/security.test.js +695 -0
  56. package/tests/unit/tools/site.test.js +1 -1
  57. package/tests/unit/tools/siteOptions.test.js +101 -0
  58. package/tests/unit/tools/users.crud.test.js +399 -0
  59. package/tests/unit/tools/validateBlocks.test.js +186 -0
  60. package/tests/unit/tools/visualStaging.test.js +271 -0
  61. package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
@@ -0,0 +1,695 @@
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 { makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ function mockFetchJson(data, status = 200) {
14
+ return Promise.resolve({
15
+ ok: status >= 200 && status < 400,
16
+ status,
17
+ headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
18
+ json: () => Promise.resolve(data),
19
+ text: () => Promise.resolve(typeof data === 'string' ? data : JSON.stringify(data))
20
+ });
21
+ }
22
+
23
+ function mockFetchText(text, status = 200) {
24
+ return Promise.resolve({
25
+ ok: status >= 200 && status < 400,
26
+ status,
27
+ headers: { get: (h) => h === 'content-type' ? 'text/html' : null },
28
+ json: () => Promise.reject(new Error('not json')),
29
+ text: () => Promise.resolve(text)
30
+ });
31
+ }
32
+
33
+ let consoleSpy;
34
+ const envBackup = {};
35
+
36
+ beforeEach(() => {
37
+ fetch.mockReset();
38
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
39
+ envBackup.WPSCAN_API_KEY = process.env.WPSCAN_API_KEY;
40
+ delete process.env.WPSCAN_API_KEY;
41
+ });
42
+ afterEach(() => {
43
+ consoleSpy.mockRestore();
44
+ if (envBackup.WPSCAN_API_KEY === undefined) delete process.env.WPSCAN_API_KEY;
45
+ else process.env.WPSCAN_API_KEY = envBackup.WPSCAN_API_KEY;
46
+ });
47
+
48
+ // ════════════════════════════════════════════════════════════
49
+ // wp_audit_user_security
50
+ // ════════════════════════════════════════════════════════════
51
+
52
+ describe('wp_audit_user_security', () => {
53
+ it('detects default "admin" username as CRITICAL', async () => {
54
+ fetch.mockImplementation((u) => {
55
+ if (u.includes('/users')) return mockFetchJson([
56
+ { id: 1, slug: 'admin', email: 'admin@company.com', name: 'Admin', registered_date: '2020-01-01' }
57
+ ]);
58
+ if (u.includes('/user-activity')) return mockFetchJson({ users: [] });
59
+ if (u.includes('/plugins')) return mockFetchJson([]);
60
+ return mockFetchJson({});
61
+ });
62
+ const res = await call('wp_audit_user_security');
63
+ const d = parseResult(res);
64
+ expect(d.users[0].risk_level).toBe('critical');
65
+ expect(d.users[0].risks[0].level).toBe('critical');
66
+ expect(d.users[0].risks[0].reason).toContain('Default username');
67
+ expect(d.summary.critical).toBe(1);
68
+ });
69
+
70
+ it('flags generic email on admin as HIGH', async () => {
71
+ fetch.mockImplementation((u) => {
72
+ if (u.includes('/users')) return mockFetchJson([
73
+ { id: 2, slug: 'john', email: 'john@gmail.com', name: 'John D', registered_date: '2021-01-01' }
74
+ ]);
75
+ if (u.includes('/user-activity')) return mockFetchJson({ users: [] });
76
+ if (u.includes('/plugins')) return mockFetchJson([]);
77
+ return mockFetchJson({});
78
+ });
79
+ const res = await call('wp_audit_user_security');
80
+ const d = parseResult(res);
81
+ const emailRisk = d.users[0].risks.find(r => r.reason.includes('Generic email'));
82
+ expect(emailRisk).toBeTruthy();
83
+ expect(emailRisk.level).toBe('high');
84
+ });
85
+
86
+ it('flags inactivity beyond threshold as HIGH', async () => {
87
+ const oldDate = new Date(Date.now() - 120 * 86400000).toISOString();
88
+ fetch.mockImplementation((u) => {
89
+ if (u.includes('/users')) return mockFetchJson([
90
+ { id: 3, slug: 'editor1', email: 'editor@company.com', name: 'Editor', registered_date: '2020-06-01' }
91
+ ]);
92
+ if (u.includes('/user-activity')) return mockFetchJson({ users: [{ user_id: 3, last_login: oldDate }] });
93
+ if (u.includes('/plugins')) return mockFetchJson([]);
94
+ return mockFetchJson({});
95
+ });
96
+ const res = await call('wp_audit_user_security', { days: 90 });
97
+ const d = parseResult(res);
98
+ const inactiveRisk = d.users[0].risks.find(r => r.reason.includes('No activity'));
99
+ expect(inactiveRisk).toBeTruthy();
100
+ expect(inactiveRisk.level).toBe('high');
101
+ });
102
+
103
+ it('detects missing 2FA as MEDIUM', async () => {
104
+ fetch.mockImplementation((u) => {
105
+ if (u.includes('/users')) return mockFetchJson([
106
+ { id: 4, slug: 'secuser', email: 'sec@company.com', name: 'SecUser', registered_date: '2022-01-01' }
107
+ ]);
108
+ if (u.includes('/user-activity')) return mockFetchJson({ users: [] });
109
+ if (u.includes('/plugins')) return mockFetchJson([]);
110
+ return mockFetchJson({});
111
+ });
112
+ const res = await call('wp_audit_user_security');
113
+ const d = parseResult(res);
114
+ const tfaRisk = d.users[0].risks.find(r => r.reason.includes('No 2FA'));
115
+ expect(tfaRisk).toBeTruthy();
116
+ expect(tfaRisk.level).toBe('medium');
117
+ });
118
+
119
+ it('flags display_name matching login as LOW', async () => {
120
+ fetch.mockImplementation((u) => {
121
+ if (u.includes('/users')) return mockFetchJson([
122
+ { id: 5, slug: 'johndoe', email: 'john@company.com', name: 'johndoe', registered_date: '2022-01-01' }
123
+ ]);
124
+ if (u.includes('/user-activity')) return mockFetchJson({ users: [] });
125
+ if (u.includes('/plugins')) return mockFetchJson([{ plugin: 'wp-2fa/wp-2fa.php', status: 'active' }]);
126
+ return mockFetchJson({});
127
+ });
128
+ const res = await call('wp_audit_user_security');
129
+ const d = parseResult(res);
130
+ const dispRisk = d.users[0].risks.find(r => r.reason.includes('Display name identical'));
131
+ expect(dispRisk).toBeTruthy();
132
+ expect(dispRisk.level).toBe('low');
133
+ });
134
+
135
+ it('returns no risks for a clean account', async () => {
136
+ const recentDate = new Date(Date.now() - 2 * 86400000).toISOString();
137
+ fetch.mockImplementation((u) => {
138
+ if (u.includes('/users')) return mockFetchJson([
139
+ { id: 6, slug: 'goodadmin', email: 'admin@mycompany.be', name: 'Good Admin', registered_date: '2022-01-01' }
140
+ ]);
141
+ if (u.includes('/user-activity')) return mockFetchJson({ users: [{ user_id: 6, last_login: recentDate }] });
142
+ if (u.includes('/plugins')) return mockFetchJson([{ plugin: 'wordfence/wordfence.php', status: 'active' }]);
143
+ return mockFetchJson({});
144
+ });
145
+ const res = await call('wp_audit_user_security');
146
+ const d = parseResult(res);
147
+ expect(d.users[0].risk_level).toBe('none');
148
+ expect(d.users[0].risks).toHaveLength(0);
149
+ });
150
+
151
+ it('reports 2FA plugin when detected', async () => {
152
+ fetch.mockImplementation((u) => {
153
+ if (u.includes('/users')) return mockFetchJson([{ id: 7, slug: 'u7', email: 'u@co.com', name: 'U7' }]);
154
+ if (u.includes('/user-activity')) return mockFetchJson({ users: [] });
155
+ if (u.includes('/plugins')) return mockFetchJson([{ plugin: 'two-factor/two-factor.php', status: 'active' }]);
156
+ return mockFetchJson({});
157
+ });
158
+ const res = await call('wp_audit_user_security');
159
+ const d = parseResult(res);
160
+ expect(d.plugins_checked.two_factor_plugin).toContain('two-factor');
161
+ });
162
+ });
163
+
164
+ // ════════════════════════════════════════════════════════════
165
+ // wp_check_file_permissions
166
+ // ════════════════════════════════════════════════════════════
167
+
168
+ describe('wp_check_file_permissions', () => {
169
+ it('returns ok for correct permissions', async () => {
170
+ fetch.mockImplementation((u) => {
171
+ if (u.includes('/file-permissions')) return mockFetchJson({ files: [
172
+ { path: 'wp-config.php', permission_octal: '400', permission_human: 'r--------', recommended: '400', exists: true },
173
+ { path: '.htaccess', permission_octal: '644', permission_human: 'rw-r--r--', recommended: '644', exists: true },
174
+ { path: 'wp-content/uploads', permission_octal: '755', permission_human: 'rwxr-xr-x', recommended: '755', exists: true },
175
+ ] });
176
+ return mockFetchJson({});
177
+ });
178
+ const res = await call('wp_check_file_permissions');
179
+ const d = parseResult(res);
180
+ expect(d.overall_status).toBe('secure');
181
+ expect(d.files.every(f => f.status === 'ok')).toBe(true);
182
+ });
183
+
184
+ it('detects critical wp-config.php permissions', async () => {
185
+ fetch.mockImplementation((u) => {
186
+ if (u.includes('/file-permissions')) return mockFetchJson({ files: [
187
+ { path: 'wp-config.php', permission_octal: '644', permission_human: 'rw-r--r--', recommended: '400', exists: true },
188
+ ] });
189
+ return mockFetchJson({});
190
+ });
191
+ const res = await call('wp_check_file_permissions');
192
+ const d = parseResult(res);
193
+ expect(d.files[0].status).toBe('critical');
194
+ expect(d.files[0].fix_command).toContain('chmod 400');
195
+ expect(d.overall_status).toBe('critical');
196
+ });
197
+
198
+ it('detects critical 777 permissions', async () => {
199
+ fetch.mockImplementation((u) => {
200
+ if (u.includes('/file-permissions')) return mockFetchJson({ files: [
201
+ { path: 'wp-content/uploads', permission_octal: '777', permission_human: 'rwxrwxrwx', recommended: '755', exists: true },
202
+ ] });
203
+ return mockFetchJson({});
204
+ });
205
+ const res = await call('wp_check_file_permissions');
206
+ const d = parseResult(res);
207
+ expect(d.files[0].status).toBe('critical');
208
+ expect(d.overall_status).toBe('critical');
209
+ });
210
+
211
+ it('detects warning-level permissions', async () => {
212
+ fetch.mockImplementation((u) => {
213
+ if (u.includes('/file-permissions')) return mockFetchJson({ files: [
214
+ { path: 'wp-admin', permission_octal: '775', permission_human: 'rwxrwxr-x', recommended: '755', exists: true },
215
+ ] });
216
+ return mockFetchJson({});
217
+ });
218
+ const res = await call('wp_check_file_permissions');
219
+ const d = parseResult(res);
220
+ expect(d.files[0].status).toBe('warning');
221
+ expect(d.overall_status).toBe('warnings');
222
+ });
223
+
224
+ it('handles mu-plugin not installed gracefully', async () => {
225
+ fetch.mockImplementation(() => mockFetchJson({ code: 'rest_no_route', message: 'No route' }, 404));
226
+ const res = await call('wp_check_file_permissions');
227
+ const d = parseResult(res);
228
+ expect(d.overall_status).toBe('unavailable');
229
+ expect(d.message).toContain('mu-plugin');
230
+ });
231
+
232
+ it('provides fix_command for problematic files', async () => {
233
+ fetch.mockImplementation((u) => {
234
+ if (u.includes('/file-permissions')) return mockFetchJson({ files: [
235
+ { path: '.htaccess', permission_octal: '755', permission_human: 'rwxr-xr-x', recommended: '644', exists: true },
236
+ ] });
237
+ return mockFetchJson({});
238
+ });
239
+ const res = await call('wp_check_file_permissions');
240
+ const d = parseResult(res);
241
+ expect(d.files[0].fix_command).toBe('chmod 644 .htaccess');
242
+ });
243
+ });
244
+
245
+ // ════════════════════════════════════════════════════════════
246
+ // wp_list_recently_modified_files
247
+ // ════════════════════════════════════════════════════════════
248
+
249
+ describe('wp_list_recently_modified_files', () => {
250
+ it('identifies legitimate file (within update window)', async () => {
251
+ const now = new Date().toISOString();
252
+ fetch.mockImplementation((u) => {
253
+ if (u.includes('/modified-files')) return mockFetchJson({ files: [
254
+ { path: 'wp-content/plugins/akismet/akismet.php', modified_at: now, size: 5000, extension: '.php' }
255
+ ] });
256
+ if (u.includes('/plugins')) return mockFetchJson([
257
+ { plugin: 'akismet/akismet.php', status: 'active', updated: now }
258
+ ]);
259
+ return mockFetchJson({});
260
+ });
261
+ const res = await call('wp_list_recently_modified_files');
262
+ const d = parseResult(res);
263
+ expect(d.files[0].suspicious).toBe(false);
264
+ expect(d.summary.suspicious_count).toBe(0);
265
+ expect(d.alert).toBe(false);
266
+ });
267
+
268
+ it('flags PHP in uploads as suspicious', async () => {
269
+ fetch.mockImplementation((u) => {
270
+ if (u.includes('/modified-files')) return mockFetchJson({ files: [
271
+ { path: 'wp-content/uploads/2024/backdoor.php', modified_at: new Date().toISOString(), size: 1234, extension: '.php' }
272
+ ] });
273
+ if (u.includes('/plugins')) return mockFetchJson([]);
274
+ return mockFetchJson({});
275
+ });
276
+ const res = await call('wp_list_recently_modified_files');
277
+ const d = parseResult(res);
278
+ expect(d.files[0].suspicious).toBe(true);
279
+ expect(d.files[0].reason).toContain('PHP file in uploads');
280
+ expect(d.alert).toBe(true);
281
+ });
282
+
283
+ it('flags random hex filename as suspicious', async () => {
284
+ fetch.mockImplementation((u) => {
285
+ if (u.includes('/modified-files')) return mockFetchJson({ files: [
286
+ { path: 'wp-content/plugins/myplugin/a1b2c3d4e5f6.php', modified_at: new Date().toISOString(), size: 800, extension: '.php' }
287
+ ] });
288
+ if (u.includes('/plugins')) return mockFetchJson([]);
289
+ return mockFetchJson({});
290
+ });
291
+ const res = await call('wp_list_recently_modified_files');
292
+ const d = parseResult(res);
293
+ expect(d.files[0].suspicious).toBe(true);
294
+ expect(d.files[0].reason).toContain('Random hex');
295
+ });
296
+
297
+ it('flags files modified outside plugin update window', async () => {
298
+ const fileTime = new Date().toISOString();
299
+ const updateTime = new Date(Date.now() - 5 * 86400000).toISOString();
300
+ fetch.mockImplementation((u) => {
301
+ if (u.includes('/modified-files')) return mockFetchJson({ files: [
302
+ { path: 'wp-content/plugins/contact-form-7/cf7.php', modified_at: fileTime, size: 3000, extension: '.php' }
303
+ ] });
304
+ if (u.includes('/plugins')) return mockFetchJson([
305
+ { plugin: 'contact-form-7/cf7.php', status: 'active', updated: updateTime }
306
+ ]);
307
+ return mockFetchJson({});
308
+ });
309
+ const res = await call('wp_list_recently_modified_files');
310
+ const d = parseResult(res);
311
+ expect(d.files[0].suspicious).toBe(true);
312
+ expect(d.files[0].reason).toContain('outside plugin update window');
313
+ });
314
+
315
+ it('handles mu-plugin not installed gracefully', async () => {
316
+ fetch.mockImplementation(() => mockFetchJson({ code: 'rest_no_route' }, 404));
317
+ const res = await call('wp_list_recently_modified_files');
318
+ const d = parseResult(res);
319
+ expect(d.message).toContain('mu-plugin');
320
+ expect(d.files).toHaveLength(0);
321
+ });
322
+ });
323
+
324
+ // ════════════════════════════════════════════════════════════
325
+ // wp_audit_plugin_vulnerabilities
326
+ // ════════════════════════════════════════════════════════════
327
+
328
+ describe('wp_audit_plugin_vulnerabilities', () => {
329
+ it('returns CVE data when WPSCAN_API_KEY is present', async () => {
330
+ process.env.WPSCAN_API_KEY = 'test-key-123';
331
+ fetch.mockImplementation((u) => {
332
+ if (u.includes('wpscan.com')) return Promise.resolve({
333
+ ok: true,
334
+ status: 200,
335
+ headers: { get: (h) => h === 'x-ratelimit-remaining' ? '24' : h === 'content-type' ? 'application/json' : null },
336
+ json: () => Promise.resolve({
337
+ 'contact-form-7': {
338
+ vulnerabilities: [{
339
+ title: 'XSS in CF7',
340
+ references: { cve: ['2024-1234'], url: ['https://example.com'] },
341
+ cvss: { score: 7.5 },
342
+ fixed_in: '5.8'
343
+ }]
344
+ }
345
+ })
346
+ });
347
+ if (u.includes('/plugins')) return mockFetchJson([
348
+ { plugin: 'contact-form-7/cf7.php', name: 'Contact Form 7', version: '5.7', status: 'active' }
349
+ ]);
350
+ return mockFetchJson({});
351
+ });
352
+ const res = await call('wp_audit_plugin_vulnerabilities');
353
+ const d = parseResult(res);
354
+ expect(d.api_source).toBe('wpscan');
355
+ expect(d.vulnerable).toHaveLength(1);
356
+ expect(d.vulnerable[0].vulnerabilities[0].cve_id).toBe('CVE-2024-1234');
357
+ expect(d.vulnerable[0].vulnerabilities[0].severity).toBe('high');
358
+ expect(d.wpscan_quota_remaining).toBe(24);
359
+ });
360
+
361
+ it('returns plugin list without CVEs when WPSCAN_API_KEY absent', async () => {
362
+ fetch.mockImplementation((u) => {
363
+ if (u.includes('/plugins')) return mockFetchJson([
364
+ { plugin: 'akismet/akismet.php', name: 'Akismet', version: '5.0', status: 'active' }
365
+ ]);
366
+ return mockFetchJson({});
367
+ });
368
+ const res = await call('wp_audit_plugin_vulnerabilities');
369
+ const d = parseResult(res);
370
+ expect(d.api_source).toBe('none');
371
+ expect(d.message).toContain('WPSCAN_API_KEY');
372
+ expect(d.plugins).toHaveLength(1);
373
+ expect(d.plugins[0].slug).toBe('akismet');
374
+ });
375
+
376
+ it('respects severity_filter', async () => {
377
+ process.env.WPSCAN_API_KEY = 'test-key';
378
+ fetch.mockImplementation((u) => {
379
+ if (u.includes('wpscan.com')) return Promise.resolve({
380
+ ok: true, status: 200,
381
+ headers: { get: () => null },
382
+ json: () => Promise.resolve({
383
+ 'vulnplugin': {
384
+ vulnerabilities: [
385
+ { title: 'Low vuln', cvss: { score: 2.0 }, fixed_in: '2.0', references: {} },
386
+ { title: 'Critical vuln', cvss: { score: 9.5 }, fixed_in: '2.0', references: {} }
387
+ ]
388
+ }
389
+ })
390
+ });
391
+ if (u.includes('/plugins')) return mockFetchJson([
392
+ { plugin: 'vulnplugin/vulnplugin.php', name: 'VulnPlugin', version: '1.0', status: 'active' }
393
+ ]);
394
+ return mockFetchJson({});
395
+ });
396
+ const res = await call('wp_audit_plugin_vulnerabilities', { severity_filter: 'critical' });
397
+ const d = parseResult(res);
398
+ if (d.vulnerable.length > 0) {
399
+ expect(d.vulnerable[0].vulnerabilities.every(v => v.severity === 'critical')).toBe(true);
400
+ }
401
+ });
402
+
403
+ it('warns about rate limiting for >25 plugins', async () => {
404
+ process.env.WPSCAN_API_KEY = 'test-key';
405
+ const manyPlugins = Array.from({ length: 26 }, (_, i) => ({
406
+ plugin: `plugin-${i}/plugin-${i}.php`, name: `Plugin ${i}`, version: '1.0', status: 'active'
407
+ }));
408
+ fetch.mockImplementation((u) => {
409
+ if (u.includes('wpscan.com')) return Promise.resolve({
410
+ ok: true, status: 200,
411
+ headers: { get: () => null },
412
+ json: () => Promise.resolve({ 'unknown': { vulnerabilities: [] } })
413
+ });
414
+ if (u.includes('/plugins')) return mockFetchJson(manyPlugins);
415
+ return mockFetchJson({});
416
+ });
417
+ const res = await call('wp_audit_plugin_vulnerabilities');
418
+ const d = parseResult(res);
419
+ expect(d.plugins_scanned).toBe(26);
420
+ // Check that warn log was called about rate limiting
421
+ const warnCalls = consoleSpy.mock.calls.filter(c => c[0] && c[0].includes('Scanning 26 plugins'));
422
+ expect(warnCalls.length).toBeGreaterThan(0);
423
+ });
424
+ });
425
+
426
+ // ════════════════════════════════════════════════════════════
427
+ // wp_check_ssl_certificate
428
+ // ════════════════════════════════════════════════════════════
429
+
430
+ describe('wp_check_ssl_certificate', () => {
431
+ it('returns grade A+ with HSTS and all headers', async () => {
432
+ // Mock tls module via the handler's behavior
433
+ const futureDate = new Date(Date.now() + 365 * 86400000).toISOString();
434
+ fetch.mockImplementation((u) => {
435
+ if (u.startsWith('https://')) return Promise.resolve({
436
+ ok: true, status: 200,
437
+ headers: {
438
+ get: (h) => {
439
+ if (h === 'strict-transport-security') return 'max-age=31536000; includeSubDomains';
440
+ if (h === 'x-content-type-options') return 'nosniff';
441
+ if (h === 'x-frame-options') return 'DENY';
442
+ if (h === 'content-security-policy') return "default-src 'self'";
443
+ return null;
444
+ }
445
+ },
446
+ text: () => Promise.resolve('')
447
+ });
448
+ return mockFetchJson({});
449
+ });
450
+ const res = await call('wp_check_ssl_certificate', { domain: 'example.com' });
451
+ const d = parseResult(res);
452
+ expect(d.domain).toBe('example.com');
453
+ expect(d.hsts.present).toBe(true);
454
+ expect(d.security_headers.x_content_type).toBe(true);
455
+ expect(d.security_headers.x_frame_options).toBe(true);
456
+ // Grade depends on TLS handshake which we can't fully mock here
457
+ expect(['A+', 'A', 'B', 'F']).toContain(d.grade);
458
+ });
459
+
460
+ it('returns grade B when HSTS absent', async () => {
461
+ fetch.mockImplementation((u) => {
462
+ if (u.startsWith('https://')) return Promise.resolve({
463
+ ok: true, status: 200,
464
+ headers: { get: () => null },
465
+ text: () => Promise.resolve('')
466
+ });
467
+ return mockFetchJson({});
468
+ });
469
+ const res = await call('wp_check_ssl_certificate', { domain: 'nohsts.com' });
470
+ const d = parseResult(res);
471
+ expect(d.hsts.present).toBe(false);
472
+ // Without TLS mock, grade depends on cert validity
473
+ expect(d.domain).toBe('nohsts.com');
474
+ });
475
+
476
+ it('flags expires_soon when certificate nearing expiry', async () => {
477
+ fetch.mockImplementation((u) => {
478
+ if (u.startsWith('https://')) return Promise.resolve({
479
+ ok: true, status: 200,
480
+ headers: { get: () => null },
481
+ text: () => Promise.resolve('')
482
+ });
483
+ return mockFetchJson({});
484
+ });
485
+ const res = await call('wp_check_ssl_certificate', { domain: 'expiring.com', warn_days: 30 });
486
+ const d = parseResult(res);
487
+ expect(d.domain).toBe('expiring.com');
488
+ // Structure check
489
+ expect(d).toHaveProperty('grade');
490
+ expect(d).toHaveProperty('hsts');
491
+ expect(d).toHaveProperty('security_headers');
492
+ });
493
+
494
+ it('returns alert when grade is low', async () => {
495
+ fetch.mockImplementation(() => Promise.reject(new Error('connection refused')));
496
+ const res = await call('wp_check_ssl_certificate', { domain: 'invalid.test' });
497
+ const d = parseResult(res);
498
+ expect(d.grade).toBe('F');
499
+ expect(d.valid).toBe(false);
500
+ });
501
+ });
502
+
503
+ // ════════════════════════════════════════════════════════════
504
+ // wp_audit_login_security
505
+ // ════════════════════════════════════════════════════════════
506
+
507
+ describe('wp_audit_login_security', () => {
508
+ it('returns score 100 when all checks pass', async () => {
509
+ fetch.mockImplementation((u, opts) => {
510
+ // xmlrpc.php blocked
511
+ if (u.includes('/xmlrpc.php')) return mockFetchJson({}, 403);
512
+ // user enum blocked
513
+ if (u.includes('/wp/v2/users') && !opts?.headers?.Authorization) return mockFetchJson({}, 403);
514
+ if (u.includes('/wp/v2/users')) return mockFetchJson([{ id: 1, slug: 'myadmin' }]);
515
+ // login URL hidden
516
+ if (u.includes('/wp-login.php')) return mockFetchText('', 404);
517
+ // plugins with 2FA and brute force
518
+ if (u.includes('/plugins')) return mockFetchJson([
519
+ { plugin: 'wordfence/wordfence.php', status: 'active' },
520
+ { plugin: 'limit-login-attempts-reloaded/llar.php', status: 'active' }
521
+ ]);
522
+ return mockFetchJson({});
523
+ });
524
+ const res = await call('wp_audit_login_security');
525
+ const d = parseResult(res);
526
+ expect(d.score).toBe(100);
527
+ expect(d.grade).toBe('A');
528
+ expect(d.checks).toHaveLength(6);
529
+ expect(d.checks.every(c => c.passed)).toBe(true);
530
+ });
531
+
532
+ it('deducts 20 for vulnerable XML-RPC', async () => {
533
+ fetch.mockImplementation((u, opts) => {
534
+ if (u.includes('/xmlrpc.php')) return mockFetchText('XML-RPC server accepts POST requests only.', 405);
535
+ if (u.includes('/wp/v2/users') && !opts?.headers?.Authorization) return mockFetchJson({}, 403);
536
+ if (u.includes('/wp/v2/users')) return mockFetchJson([{ id: 1, slug: 'safeuser' }]);
537
+ if (u.includes('/wp-login.php')) return mockFetchText('', 404);
538
+ if (u.includes('/plugins')) return mockFetchJson([
539
+ { plugin: 'wordfence/wordfence.php', status: 'active' }
540
+ ]);
541
+ return mockFetchJson({});
542
+ });
543
+ const res = await call('wp_audit_login_security');
544
+ const d = parseResult(res);
545
+ const xmlrpcCheck = d.checks.find(c => c.name === 'xmlrpc_disabled');
546
+ expect(xmlrpcCheck.passed).toBe(false);
547
+ expect(xmlrpcCheck.score_awarded).toBe(0);
548
+ expect(d.score).toBeLessThanOrEqual(80);
549
+ });
550
+
551
+ it('deducts 20 for open user enumeration', async () => {
552
+ fetch.mockImplementation((u, opts) => {
553
+ if (u.includes('/xmlrpc.php')) return mockFetchJson({}, 403);
554
+ // User enum open — returns 200 with data
555
+ if (u.includes('/wp/v2/users')) return mockFetchJson([{ id: 1, slug: 'safeuser' }]);
556
+ if (u.includes('/wp-login.php')) return mockFetchText('', 404);
557
+ if (u.includes('/plugins')) return mockFetchJson([
558
+ { plugin: 'wordfence/wordfence.php', status: 'active' }
559
+ ]);
560
+ return mockFetchJson({});
561
+ });
562
+ const res = await call('wp_audit_login_security');
563
+ const d = parseResult(res);
564
+ const enumCheck = d.checks.find(c => c.name === 'user_enumeration_blocked');
565
+ expect(enumCheck.passed).toBe(false);
566
+ });
567
+
568
+ it('returns grade F when score < 40', async () => {
569
+ fetch.mockImplementation((u) => {
570
+ if (u.includes('/xmlrpc.php')) return mockFetchText('XML-RPC', 200);
571
+ if (u.includes('/wp/v2/users')) return mockFetchJson([{ id: 1, slug: 'admin' }]);
572
+ if (u.includes('/wp-login.php')) return mockFetchText('<html>', 200);
573
+ if (u.includes('/plugins')) return mockFetchJson([]);
574
+ return mockFetchJson({});
575
+ });
576
+ const res = await call('wp_audit_login_security');
577
+ const d = parseResult(res);
578
+ expect(d.score).toBeLessThan(40);
579
+ expect(d.grade).toBe('F');
580
+ });
581
+
582
+ it('lists detected security plugins', async () => {
583
+ fetch.mockImplementation((u) => {
584
+ if (u.includes('/xmlrpc.php')) return mockFetchJson({}, 403);
585
+ if (u.includes('/wp/v2/users')) return mockFetchJson([{ id: 1, slug: 'admin' }], 403);
586
+ if (u.includes('/wp-login.php')) return mockFetchText('', 404);
587
+ if (u.includes('/plugins')) return mockFetchJson([
588
+ { plugin: 'wordfence/wordfence.php', status: 'active' },
589
+ { plugin: 'sucuri-scanner/sucuri.php', status: 'active' }
590
+ ]);
591
+ return mockFetchJson({});
592
+ });
593
+ const res = await call('wp_audit_login_security');
594
+ const d = parseResult(res);
595
+ expect(d.plugins_detected.security_plugins.length).toBeGreaterThanOrEqual(1);
596
+ });
597
+
598
+ it('includes recommendations for failed checks', async () => {
599
+ fetch.mockImplementation((u) => {
600
+ if (u.includes('/xmlrpc.php')) return mockFetchText('', 200);
601
+ if (u.includes('/wp/v2/users')) return mockFetchJson([{ id: 1, slug: 'admin' }]);
602
+ if (u.includes('/wp-login.php')) return mockFetchText('<html>', 200);
603
+ if (u.includes('/plugins')) return mockFetchJson([]);
604
+ return mockFetchJson({});
605
+ });
606
+ const res = await call('wp_audit_login_security');
607
+ const d = parseResult(res);
608
+ const failedChecks = d.checks.filter(c => !c.passed);
609
+ expect(failedChecks.length).toBeGreaterThan(0);
610
+ failedChecks.forEach(c => expect(c.recommendation).toBeTruthy());
611
+ });
612
+
613
+ it('awards 15pts for brute force protection', async () => {
614
+ fetch.mockImplementation((u) => {
615
+ if (u.includes('/xmlrpc.php')) return mockFetchJson({}, 403);
616
+ if (u.includes('/wp/v2/users') && !u.includes('per_page')) return mockFetchJson([], 403);
617
+ if (u.includes('/wp/v2/users')) return mockFetchJson([{ id: 1, slug: 'safeadmin' }]);
618
+ if (u.includes('/wp-login.php')) return mockFetchText('', 404);
619
+ if (u.includes('/plugins')) return mockFetchJson([
620
+ { plugin: 'limit-login-attempts-reloaded/llar.php', status: 'active' }
621
+ ]);
622
+ return mockFetchJson({});
623
+ });
624
+ const res = await call('wp_audit_login_security');
625
+ const d = parseResult(res);
626
+ const bfCheck = d.checks.find(c => c.name === 'brute_force_protection');
627
+ expect(bfCheck.passed).toBe(true);
628
+ expect(bfCheck.score_awarded).toBe(15);
629
+ });
630
+ });
631
+
632
+ // ════════════════════════════════════════════════════════════
633
+ // Additional coverage
634
+ // ════════════════════════════════════════════════════════════
635
+
636
+ describe('wp_audit_user_security — additional', () => {
637
+ it('respects include_generic_emails=false', async () => {
638
+ fetch.mockImplementation((u) => {
639
+ if (u.includes('/users')) return mockFetchJson([
640
+ { id: 10, slug: 'testuser', email: 'test@gmail.com', name: 'Test', registered_date: '2022-01-01' }
641
+ ]);
642
+ if (u.includes('/user-activity')) return mockFetchJson({ users: [] });
643
+ if (u.includes('/plugins')) return mockFetchJson([{ plugin: 'wp-2fa/wp-2fa.php', status: 'active' }]);
644
+ return mockFetchJson({});
645
+ });
646
+ const res = await call('wp_audit_user_security', { include_generic_emails: false });
647
+ const d = parseResult(res);
648
+ expect(d.users[0].risks.find(r => r.reason.includes('Generic email'))).toBeUndefined();
649
+ });
650
+
651
+ it('respects check_2fa=false', async () => {
652
+ fetch.mockImplementation((u) => {
653
+ if (u.includes('/users')) return mockFetchJson([
654
+ { id: 11, slug: 'nocheckuser', email: 'user@company.com', name: 'NoCheck', registered_date: '2022-01-01' }
655
+ ]);
656
+ if (u.includes('/user-activity')) return mockFetchJson({ users: [] });
657
+ if (u.includes('/plugins')) return mockFetchJson([]);
658
+ return mockFetchJson({});
659
+ });
660
+ const res = await call('wp_audit_user_security', { check_2fa: false });
661
+ const d = parseResult(res);
662
+ expect(d.users[0].risks.find(r => r.reason.includes('2FA'))).toBeUndefined();
663
+ });
664
+
665
+ it('returns total_admins count', async () => {
666
+ fetch.mockImplementation((u) => {
667
+ if (u.includes('/users')) return mockFetchJson([
668
+ { id: 20, slug: 'a1', email: 'a@co.com', name: 'A1' },
669
+ { id: 21, slug: 'a2', email: 'b@co.com', name: 'A2' },
670
+ { id: 22, slug: 'a3', email: 'c@co.com', name: 'A3' }
671
+ ]);
672
+ if (u.includes('/user-activity')) return mockFetchJson({ users: [] });
673
+ if (u.includes('/plugins')) return mockFetchJson([{ plugin: 'two-factor/two-factor.php', status: 'active' }]);
674
+ return mockFetchJson({});
675
+ });
676
+ const res = await call('wp_audit_user_security');
677
+ const d = parseResult(res);
678
+ expect(d.summary.total_admins).toBe(3);
679
+ });
680
+ });
681
+
682
+ describe('wp_audit_plugin_vulnerabilities — additional', () => {
683
+ it('excludes inactive plugins by default', async () => {
684
+ fetch.mockImplementation((u) => {
685
+ if (u.includes('/plugins')) return mockFetchJson([
686
+ { plugin: 'active-plugin/active.php', name: 'Active', version: '1.0', status: 'active' },
687
+ { plugin: 'inactive-plugin/inactive.php', name: 'Inactive', version: '1.0', status: 'inactive' }
688
+ ]);
689
+ return mockFetchJson({});
690
+ });
691
+ const res = await call('wp_audit_plugin_vulnerabilities');
692
+ const d = parseResult(res);
693
+ expect(d.plugins_scanned).toBe(1);
694
+ });
695
+ });