@adsim/wordpress-mcp-server 4.5.1 → 4.6.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/README.md +69 -11
- package/dxt/manifest.json +17 -6
- package/index.js +67 -6
- package/package.json +1 -1
- package/src/plugins/IPluginAdapter.js +95 -0
- package/src/plugins/adapters/acf/acfAdapter.js +181 -0
- package/src/plugins/adapters/elementor/elementorAdapter.js +176 -0
- package/src/plugins/contextGuard.js +57 -0
- package/src/plugins/registry.js +94 -0
- package/tests/unit/pluginLayer.test.js +151 -0
- package/tests/unit/plugins/acf/acfAdapter.test.js +205 -0
- package/tests/unit/plugins/acf/acfAdapter.write.test.js +157 -0
- package/tests/unit/plugins/contextGuard.test.js +51 -0
- package/tests/unit/plugins/elementor/elementorAdapter.test.js +206 -0
- package/tests/unit/plugins/iPluginAdapter.test.js +34 -0
- package/tests/unit/plugins/registry.test.js +84 -0
- package/tests/unit/tools/dynamicFiltering.test.js +4 -4
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/siteOptions.test.js +101 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { acfAdapter } from '../../../../src/plugins/adapters/acf/acfAdapter.js';
|
|
3
|
+
|
|
4
|
+
let consoleSpy;
|
|
5
|
+
const envBackup = {};
|
|
6
|
+
|
|
7
|
+
function saveEnv(...keys) {
|
|
8
|
+
keys.forEach(k => { envBackup[k] = process.env[k]; });
|
|
9
|
+
}
|
|
10
|
+
function restoreEnv() {
|
|
11
|
+
Object.entries(envBackup).forEach(([k, v]) => {
|
|
12
|
+
if (v === undefined) delete process.env[k];
|
|
13
|
+
else process.env[k] = v;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
19
|
+
saveEnv('WP_READ_ONLY');
|
|
20
|
+
delete process.env.WP_READ_ONLY;
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
consoleSpy.mockRestore();
|
|
24
|
+
restoreEnv();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ── Helpers ──
|
|
28
|
+
|
|
29
|
+
function parseResult(result) {
|
|
30
|
+
return JSON.parse(result?.content?.[0]?.text);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getAuditLogs() {
|
|
34
|
+
return consoleSpy.mock.calls
|
|
35
|
+
.map(c => c[0])
|
|
36
|
+
.filter(m => typeof m === 'string' && m.startsWith('[AUDIT]'))
|
|
37
|
+
.map(m => JSON.parse(m.replace('[AUDIT] ', '')));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mockApi(data, shouldThrow = false) {
|
|
41
|
+
if (shouldThrow) {
|
|
42
|
+
return async () => { throw new Error('WP API 404: Not Found'); };
|
|
43
|
+
}
|
|
44
|
+
return async () => data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mockApi403() {
|
|
48
|
+
return async () => { throw new Error('WP API 403: Forbidden'); };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Mock data ──
|
|
52
|
+
|
|
53
|
+
const mockUpdatedResponse = {
|
|
54
|
+
acf: {
|
|
55
|
+
hero_title: 'New Title',
|
|
56
|
+
price: 49.99,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// =========================================================================
|
|
61
|
+
// Tests
|
|
62
|
+
// =========================================================================
|
|
63
|
+
|
|
64
|
+
describe('ACF Adapter — acf_update_fields (write)', () => {
|
|
65
|
+
const getTool = () => acfAdapter.getTools().find(t => t.name === 'acf_update_fields');
|
|
66
|
+
|
|
67
|
+
// 1. Update réussi → retourne les champs mis à jour
|
|
68
|
+
it('successful update returns updated fields', async () => {
|
|
69
|
+
const tool = getTool();
|
|
70
|
+
const result = await tool.handler(
|
|
71
|
+
{ id: 42, post_type: 'posts', fields: { hero_title: 'New Title', price: 49.99 } },
|
|
72
|
+
mockApi(mockUpdatedResponse)
|
|
73
|
+
);
|
|
74
|
+
const data = parseResult(result);
|
|
75
|
+
expect(data.id).toBe(42);
|
|
76
|
+
expect(data.post_type).toBe('posts');
|
|
77
|
+
expect(data.updated_fields.hero_title).toBe('New Title');
|
|
78
|
+
expect(data.updated_fields.price).toBe(49.99);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// 2. WP_READ_ONLY=true → bloqué, status "blocked" dans le log
|
|
82
|
+
it('blocked when WP_READ_ONLY=true', async () => {
|
|
83
|
+
process.env.WP_READ_ONLY = 'true';
|
|
84
|
+
const tool = getTool();
|
|
85
|
+
const result = await tool.handler(
|
|
86
|
+
{ id: 42, fields: { hero_title: 'New' } },
|
|
87
|
+
mockApi(mockUpdatedResponse)
|
|
88
|
+
);
|
|
89
|
+
const data = parseResult(result);
|
|
90
|
+
expect(data.error).toContain('READ-ONLY');
|
|
91
|
+
|
|
92
|
+
const logs = getAuditLogs();
|
|
93
|
+
const entry = logs.find(l => l.tool === 'acf_update_fields');
|
|
94
|
+
expect(entry.status).toBe('blocked');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// 3. WP_READ_ONLY=false → exécution normale
|
|
98
|
+
it('executes normally when WP_READ_ONLY is not set', async () => {
|
|
99
|
+
delete process.env.WP_READ_ONLY;
|
|
100
|
+
const tool = getTool();
|
|
101
|
+
const result = await tool.handler(
|
|
102
|
+
{ id: 10, fields: { price: 99 } },
|
|
103
|
+
mockApi({ acf: { price: 99 } })
|
|
104
|
+
);
|
|
105
|
+
const data = parseResult(result);
|
|
106
|
+
expect(data.updated_fields.price).toBe(99);
|
|
107
|
+
expect(data.id).toBe(10);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// 4. id invalide (string) → erreur validation
|
|
111
|
+
it('rejects non-integer id', async () => {
|
|
112
|
+
const tool = getTool();
|
|
113
|
+
await expect(
|
|
114
|
+
tool.handler({ id: 'abc', fields: { x: 1 } }, mockApi({}))
|
|
115
|
+
).rejects.toThrow('Validation error');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// 5. fields vide {} → erreur
|
|
119
|
+
it('rejects empty fields object', async () => {
|
|
120
|
+
const tool = getTool();
|
|
121
|
+
await expect(
|
|
122
|
+
tool.handler({ id: 42, fields: {} }, mockApi({}))
|
|
123
|
+
).rejects.toThrow('Validation error');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// 6. 404 post not found → erreur propre
|
|
127
|
+
it('propagates 404 error', async () => {
|
|
128
|
+
const tool = getTool();
|
|
129
|
+
await expect(
|
|
130
|
+
tool.handler({ id: 99999, fields: { x: 1 } }, mockApi(null, true))
|
|
131
|
+
).rejects.toThrow('404');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// 7. 403 permission denied → erreur propre
|
|
135
|
+
it('propagates 403 permission error', async () => {
|
|
136
|
+
const tool = getTool();
|
|
137
|
+
await expect(
|
|
138
|
+
tool.handler({ id: 42, fields: { x: 1 } }, mockApi403())
|
|
139
|
+
).rejects.toThrow('403');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// 8. Audit log format correct
|
|
143
|
+
it('logs correct audit format with action update_acf_fields', async () => {
|
|
144
|
+
const tool = getTool();
|
|
145
|
+
await tool.handler(
|
|
146
|
+
{ id: 42, post_type: 'posts', fields: { hero_title: 'Updated' } },
|
|
147
|
+
mockApi(mockUpdatedResponse)
|
|
148
|
+
);
|
|
149
|
+
const logs = getAuditLogs();
|
|
150
|
+
const entry = logs.find(l => l.tool === 'acf_update_fields');
|
|
151
|
+
expect(entry).toBeDefined();
|
|
152
|
+
expect(entry.action).toBe('update_acf_fields');
|
|
153
|
+
expect(entry.target).toBe(42);
|
|
154
|
+
expect(entry.status).toBe('success');
|
|
155
|
+
expect(typeof entry.latency_ms).toBe('number');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { applyContextGuard } from '../../../src/plugins/contextGuard.js';
|
|
3
|
+
|
|
4
|
+
let consoleSpy;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
consoleSpy.mockRestore();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('applyContextGuard', () => {
|
|
14
|
+
it('returns data intact when below threshold', () => {
|
|
15
|
+
const data = { title: 'Hello', items: [1, 2, 3] };
|
|
16
|
+
const result = applyContextGuard(data, { toolName: 'test_tool', maxChars: 50000 });
|
|
17
|
+
expect(result).toEqual(data);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('truncates and adds _truncated when above threshold', () => {
|
|
21
|
+
const bigArray = Array.from({ length: 5000 }, (_, i) => ({ id: i, label: `item-${i}-padding-data` }));
|
|
22
|
+
const result = applyContextGuard(bigArray, { toolName: 'test_tool', maxChars: 500 });
|
|
23
|
+
expect(result._truncated).toBe(true);
|
|
24
|
+
expect(result._original_size).toBeGreaterThan(500);
|
|
25
|
+
expect(typeof result._warning).toBe('string');
|
|
26
|
+
expect(result.data).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('does NOT truncate when mode is "raw" even if above threshold', () => {
|
|
30
|
+
const bigArray = Array.from({ length: 5000 }, (_, i) => ({ id: i, label: `item-${i}-padding-data` }));
|
|
31
|
+
const result = applyContextGuard(bigArray, { toolName: 'test_tool', mode: 'raw', maxChars: 500 });
|
|
32
|
+
expect(result._truncated).toBeUndefined();
|
|
33
|
+
expect(Array.isArray(result)).toBe(true);
|
|
34
|
+
expect(result.length).toBe(5000);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('logs correct format to stderr on truncation', () => {
|
|
38
|
+
const bigArray = Array.from({ length: 5000 }, (_, i) => ({ id: i, label: `item-${i}-padding-data` }));
|
|
39
|
+
applyContextGuard(bigArray, { toolName: 'elementor_get_page', maxChars: 500 });
|
|
40
|
+
|
|
41
|
+
const logCalls = consoleSpy.mock.calls.map(c => c[0]).filter(m => typeof m === 'string');
|
|
42
|
+
const overflow = logCalls.find(m => m.includes('context_overflow'));
|
|
43
|
+
expect(overflow).toBeDefined();
|
|
44
|
+
const parsed = JSON.parse(overflow);
|
|
45
|
+
expect(parsed.tool).toBe('elementor_get_page');
|
|
46
|
+
expect(parsed.event).toBe('context_overflow');
|
|
47
|
+
expect(parsed.truncated).toBe(true);
|
|
48
|
+
expect(typeof parsed.size_chars).toBe('number');
|
|
49
|
+
expect(parsed.max_chars).toBe(500);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { elementorAdapter } from '../../../../src/plugins/adapters/elementor/elementorAdapter.js';
|
|
3
|
+
import { validateAdapter } from '../../../../src/plugins/IPluginAdapter.js';
|
|
4
|
+
import { PluginRegistry } from '../../../../src/plugins/registry.js';
|
|
5
|
+
|
|
6
|
+
let consoleSpy;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
consoleSpy.mockRestore();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// ── Helpers ──
|
|
16
|
+
|
|
17
|
+
function parseResult(result) {
|
|
18
|
+
return JSON.parse(result?.content?.[0]?.text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getAuditLogs() {
|
|
22
|
+
return consoleSpy.mock.calls
|
|
23
|
+
.map(c => c[0])
|
|
24
|
+
.filter(m => typeof m === 'string' && m.startsWith('[AUDIT]'))
|
|
25
|
+
.map(m => JSON.parse(m.replace('[AUDIT] ', '')));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function mockApi(data, shouldThrow = false) {
|
|
29
|
+
if (shouldThrow) {
|
|
30
|
+
return async () => { throw new Error('WP API 404: Not Found'); };
|
|
31
|
+
}
|
|
32
|
+
return async () => data;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Mock data ──
|
|
36
|
+
|
|
37
|
+
const mockTemplates = [
|
|
38
|
+
{ id: 1, title: { rendered: 'Hero Template' }, type: 'page', author: 1, date: '2024-06-01', source: 'local' },
|
|
39
|
+
{ id: 2, title: { rendered: 'CTA Section' }, type: 'section', author: 1, date: '2024-06-02', source: 'local' },
|
|
40
|
+
{ id: 3, title: { rendered: 'Header Block' }, type: 'block', author: 2, date: '2024-06-03', source: 'remote' },
|
|
41
|
+
{ id: 4, title: { rendered: 'Promo Popup' }, type: 'popup', author: 1, date: '2024-06-04', source: 'local' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const mockTemplateDetail = {
|
|
45
|
+
id: 1,
|
|
46
|
+
title: { rendered: 'Hero Template' },
|
|
47
|
+
type: 'page',
|
|
48
|
+
content: {
|
|
49
|
+
elements: [
|
|
50
|
+
{ elType: 'section', elements: [{ elType: 'widget', widgetType: 'heading' }] },
|
|
51
|
+
{ elType: 'section', elements: [{ elType: 'widget', widgetType: 'image' }] },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const mockPageData = {
|
|
57
|
+
elements: [
|
|
58
|
+
{ elType: 'section', widgetType: null, elements: [
|
|
59
|
+
{ elType: 'column', elements: [
|
|
60
|
+
{ elType: 'widget', widgetType: 'heading', elements: [] },
|
|
61
|
+
{ elType: 'widget', widgetType: 'text-editor', elements: [] },
|
|
62
|
+
]},
|
|
63
|
+
]},
|
|
64
|
+
{ elType: 'section', widgetType: null, elements: [
|
|
65
|
+
{ elType: 'column', elements: [
|
|
66
|
+
{ elType: 'widget', widgetType: 'image', elements: [] },
|
|
67
|
+
{ elType: 'widget', widgetType: 'heading', elements: [] },
|
|
68
|
+
]},
|
|
69
|
+
]},
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// =========================================================================
|
|
74
|
+
// Tests
|
|
75
|
+
// =========================================================================
|
|
76
|
+
|
|
77
|
+
describe('Elementor Adapter', () => {
|
|
78
|
+
|
|
79
|
+
// 1. elementor_list_templates → retourne liste avec id/title/type
|
|
80
|
+
it('elementor_list_templates returns list with id/title/type', async () => {
|
|
81
|
+
const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_list_templates');
|
|
82
|
+
const result = await tool.handler({}, mockApi(mockTemplates));
|
|
83
|
+
const data = parseResult(result);
|
|
84
|
+
expect(data.total).toBe(4);
|
|
85
|
+
expect(data.templates[0].id).toBe(1);
|
|
86
|
+
expect(data.templates[0].title).toBe('Hero Template');
|
|
87
|
+
expect(data.templates[0].type).toBe('page');
|
|
88
|
+
expect(data.templates[2].source).toBe('remote');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 2. elementor_list_templates avec type filter → filtre appliqué
|
|
92
|
+
it('elementor_list_templates filters by type', async () => {
|
|
93
|
+
const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_list_templates');
|
|
94
|
+
const result = await tool.handler({ type: 'section' }, mockApi(mockTemplates));
|
|
95
|
+
const data = parseResult(result);
|
|
96
|
+
expect(data.total).toBe(1);
|
|
97
|
+
expect(data.templates[0].id).toBe(2);
|
|
98
|
+
expect(data.templates[0].type).toBe('section');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// 3. elementor_get_template → retourne contenu complet
|
|
102
|
+
it('elementor_get_template returns full template content', async () => {
|
|
103
|
+
const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_template');
|
|
104
|
+
const result = await tool.handler({ id: 1 }, mockApi(mockTemplateDetail));
|
|
105
|
+
const data = parseResult(result);
|
|
106
|
+
expect(data.id).toBe(1);
|
|
107
|
+
expect(data.title).toBe('Hero Template');
|
|
108
|
+
expect(data.type).toBe('page');
|
|
109
|
+
expect(data.content).toBeDefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 4. elementor_get_template → contextGuard truncate si > 50k
|
|
113
|
+
it('elementor_get_template applies contextGuard on large responses', async () => {
|
|
114
|
+
// Generate a template with > 50000 chars of content
|
|
115
|
+
const bigContent = { elements: [] };
|
|
116
|
+
for (let i = 0; i < 800; i++) {
|
|
117
|
+
bigContent.elements.push({ elType: 'widget', widgetType: 'text-editor', settings: { content: 'x'.repeat(100) } });
|
|
118
|
+
}
|
|
119
|
+
const bigTemplate = { id: 99, title: { rendered: 'Big Template' }, type: 'page', content: bigContent };
|
|
120
|
+
const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_template');
|
|
121
|
+
const result = await tool.handler({ id: 99 }, mockApi(bigTemplate));
|
|
122
|
+
const data = parseResult(result);
|
|
123
|
+
expect(data._truncated).toBe(true);
|
|
124
|
+
expect(data._original_size).toBeGreaterThan(50000);
|
|
125
|
+
expect(data._warning).toContain('truncated');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// 5. elementor_get_page_data → retourne widgets_used et elements_count
|
|
129
|
+
it('elementor_get_page_data returns widgets_used and elements_count', async () => {
|
|
130
|
+
const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_page_data');
|
|
131
|
+
const result = await tool.handler({ post_id: 42 }, mockApi(mockPageData));
|
|
132
|
+
const data = parseResult(result);
|
|
133
|
+
expect(data.post_id).toBe(42);
|
|
134
|
+
expect(data.elementor_status).toBe('active');
|
|
135
|
+
expect(data.widgets_used).toContain('heading');
|
|
136
|
+
expect(data.widgets_used).toContain('text-editor');
|
|
137
|
+
expect(data.widgets_used).toContain('image');
|
|
138
|
+
expect(data.elements_count).toBeGreaterThan(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 6. elementor_get_template id invalide → erreur validation
|
|
142
|
+
it('elementor_get_template rejects invalid id', async () => {
|
|
143
|
+
const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_template');
|
|
144
|
+
await expect(
|
|
145
|
+
tool.handler({ id: 'abc' }, mockApi({}))
|
|
146
|
+
).rejects.toThrow('Validation error');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// 7. 404 template not found → erreur propre
|
|
150
|
+
it('elementor_get_template propagates 404 error', async () => {
|
|
151
|
+
const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_template');
|
|
152
|
+
await expect(
|
|
153
|
+
tool.handler({ id: 99999 }, mockApi(null, true))
|
|
154
|
+
).rejects.toThrow('404');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// 8. Adapter détecté uniquement si namespace /elementor/v1 présent
|
|
158
|
+
it('registry detects elementor only when namespace is present', async () => {
|
|
159
|
+
const registry = new PluginRegistry();
|
|
160
|
+
|
|
161
|
+
// Without elementor namespace
|
|
162
|
+
await registry.initialize(mockApi({ namespaces: ['wp/v2'] }));
|
|
163
|
+
expect(registry.isActive('elementor')).toBe(false);
|
|
164
|
+
|
|
165
|
+
// With elementor namespace
|
|
166
|
+
registry.active.clear();
|
|
167
|
+
await registry.initialize(mockApi({ namespaces: ['wp/v2', 'elementor/v1'] }));
|
|
168
|
+
expect(registry.isActive('elementor')).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// 9. Audit log format correct pour chaque outil
|
|
172
|
+
it('logs correct audit format for all 3 tools', async () => {
|
|
173
|
+
const listTool = elementorAdapter.getTools().find(t => t.name === 'elementor_list_templates');
|
|
174
|
+
const getTool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_template');
|
|
175
|
+
const pageTool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_page_data');
|
|
176
|
+
|
|
177
|
+
await listTool.handler({}, mockApi(mockTemplates));
|
|
178
|
+
await getTool.handler({ id: 1 }, mockApi(mockTemplateDetail));
|
|
179
|
+
await pageTool.handler({ post_id: 42 }, mockApi(mockPageData));
|
|
180
|
+
|
|
181
|
+
const logs = getAuditLogs();
|
|
182
|
+
|
|
183
|
+
const listEntry = logs.find(l => l.tool === 'elementor_list_templates');
|
|
184
|
+
expect(listEntry).toBeDefined();
|
|
185
|
+
expect(listEntry.action).toBe('list_templates');
|
|
186
|
+
expect(listEntry.status).toBe('success');
|
|
187
|
+
expect(typeof listEntry.latency_ms).toBe('number');
|
|
188
|
+
|
|
189
|
+
const getEntry = logs.find(l => l.tool === 'elementor_get_template');
|
|
190
|
+
expect(getEntry).toBeDefined();
|
|
191
|
+
expect(getEntry.action).toBe('read_template');
|
|
192
|
+
expect(getEntry.target).toBe(1);
|
|
193
|
+
|
|
194
|
+
const pageEntry = logs.find(l => l.tool === 'elementor_get_page_data');
|
|
195
|
+
expect(pageEntry).toBeDefined();
|
|
196
|
+
expect(pageEntry.action).toBe('read_page_data');
|
|
197
|
+
expect(pageEntry.target).toBe(42);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// 10. Passes validateAdapter
|
|
201
|
+
it('passes validateAdapter without errors', () => {
|
|
202
|
+
const { valid, errors } = validateAdapter(elementorAdapter);
|
|
203
|
+
expect(valid).toBe(true);
|
|
204
|
+
expect(errors).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateAdapter } from '../../../src/plugins/IPluginAdapter.js';
|
|
3
|
+
|
|
4
|
+
const validAdapter = {
|
|
5
|
+
id: 'acf',
|
|
6
|
+
namespace: 'acf/v3',
|
|
7
|
+
riskLevel: 'medium',
|
|
8
|
+
contextConfig: {
|
|
9
|
+
maxChars: 30000,
|
|
10
|
+
defaultMode: 'compact',
|
|
11
|
+
supportedModes: ['raw', 'compact', 'summary', 'ids_only'],
|
|
12
|
+
},
|
|
13
|
+
getTools: () => [],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('validateAdapter', () => {
|
|
17
|
+
it('validates a complete adapter', () => {
|
|
18
|
+
const { valid, errors } = validateAdapter(validAdapter);
|
|
19
|
+
expect(valid).toBe(true);
|
|
20
|
+
expect(errors).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects an adapter without id', () => {
|
|
24
|
+
const { valid, errors } = validateAdapter({ ...validAdapter, id: undefined });
|
|
25
|
+
expect(valid).toBe(false);
|
|
26
|
+
expect(errors.some(e => e.includes('"id"'))).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects an adapter without getTools', () => {
|
|
30
|
+
const { valid, errors } = validateAdapter({ ...validAdapter, getTools: undefined });
|
|
31
|
+
expect(valid).toBe(false);
|
|
32
|
+
expect(errors.some(e => e.includes('"getTools"'))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { PluginRegistry } from '../../../src/plugins/registry.js';
|
|
3
|
+
|
|
4
|
+
let consoleSpy;
|
|
5
|
+
const envBackup = {};
|
|
6
|
+
|
|
7
|
+
function saveEnv(...keys) {
|
|
8
|
+
keys.forEach(k => { envBackup[k] = process.env[k]; });
|
|
9
|
+
}
|
|
10
|
+
function restoreEnv() {
|
|
11
|
+
Object.entries(envBackup).forEach(([k, v]) => {
|
|
12
|
+
if (v === undefined) delete process.env[k];
|
|
13
|
+
else process.env[k] = v;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
19
|
+
saveEnv('WP_DISABLE_PLUGIN_LAYERS');
|
|
20
|
+
delete process.env.WP_DISABLE_PLUGIN_LAYERS;
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
consoleSpy.mockRestore();
|
|
24
|
+
restoreEnv();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function mockApiRequest(namespaces = []) {
|
|
28
|
+
return async () => ({ namespaces });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('PluginRegistry', () => {
|
|
32
|
+
it('initialize() detects ACF when "acf/v3" in namespaces', async () => {
|
|
33
|
+
const reg = new PluginRegistry();
|
|
34
|
+
await reg.initialize(mockApiRequest(['wp/v2', 'acf/v3']));
|
|
35
|
+
expect(reg.isActive('acf')).toBe(true);
|
|
36
|
+
expect(reg.isActive('elementor')).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('initialize() detects Elementor when "elementor/v1" in namespaces', async () => {
|
|
40
|
+
const reg = new PluginRegistry();
|
|
41
|
+
await reg.initialize(mockApiRequest(['wp/v2', 'elementor/v1', 'elementor/v2']));
|
|
42
|
+
expect(reg.isActive('elementor')).toBe(true);
|
|
43
|
+
expect(reg.isActive('acf')).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('initialize() detects nothing when namespaces is empty', async () => {
|
|
47
|
+
const reg = new PluginRegistry();
|
|
48
|
+
await reg.initialize(mockApiRequest([]));
|
|
49
|
+
expect(reg.isActive('acf')).toBe(false);
|
|
50
|
+
expect(reg.isActive('elementor')).toBe(false);
|
|
51
|
+
expect(reg.isActive('astra')).toBe(false);
|
|
52
|
+
expect(reg.getSummary().active).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('getAvailableTools() returns [] when WP_DISABLE_PLUGIN_LAYERS=true', async () => {
|
|
56
|
+
const reg = new PluginRegistry();
|
|
57
|
+
await reg.initialize(mockApiRequest(['acf/v3']));
|
|
58
|
+
reg.registerTools('acf', [{ name: 'acf_get_fields' }]);
|
|
59
|
+
|
|
60
|
+
process.env.WP_DISABLE_PLUGIN_LAYERS = 'true';
|
|
61
|
+
expect(reg.getAvailableTools()).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('isActive() returns true for detected plugins and false otherwise', async () => {
|
|
65
|
+
const reg = new PluginRegistry();
|
|
66
|
+
await reg.initialize(mockApiRequest(['acf/v3', 'astra/v1']));
|
|
67
|
+
expect(reg.isActive('acf')).toBe(true);
|
|
68
|
+
expect(reg.isActive('astra')).toBe(true);
|
|
69
|
+
expect(reg.isActive('elementor')).toBe(false);
|
|
70
|
+
expect(reg.isActive('nonexistent')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('getSummary() returns the correct structure', async () => {
|
|
74
|
+
const reg = new PluginRegistry();
|
|
75
|
+
await reg.initialize(mockApiRequest(['elementor/v1', 'acf/v3']));
|
|
76
|
+
const summary = reg.getSummary();
|
|
77
|
+
expect(summary.active).toContain('acf');
|
|
78
|
+
expect(summary.active).toContain('elementor');
|
|
79
|
+
expect(summary.disabled_by_env).toBe(false);
|
|
80
|
+
|
|
81
|
+
process.env.WP_DISABLE_PLUGIN_LAYERS = 'true';
|
|
82
|
+
expect(reg.getSummary().disabled_by_env).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -106,15 +106,15 @@ describe('Plugin Intelligence filtering', () => {
|
|
|
106
106
|
// =========================================================================
|
|
107
107
|
|
|
108
108
|
describe('Combined filtering counts', () => {
|
|
109
|
-
it('returns all
|
|
109
|
+
it('returns all 86 tools when all features enabled', () => {
|
|
110
110
|
process.env.WC_CONSUMER_KEY = 'ck_test';
|
|
111
111
|
process.env.WP_REQUIRE_APPROVAL = 'true';
|
|
112
112
|
process.env.WP_ENABLE_PLUGIN_INTELLIGENCE = 'true';
|
|
113
|
-
expect(getFilteredTools()).toHaveLength(
|
|
113
|
+
expect(getFilteredTools()).toHaveLength(86);
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
it('returns
|
|
117
|
-
expect(getFilteredTools()).toHaveLength(
|
|
116
|
+
it('returns 64 tools with no optional features (86 - 13wc - 3editorial - 6pi)', () => {
|
|
117
|
+
expect(getFilteredTools()).toHaveLength(64);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
120
|
|
|
@@ -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(86);
|
|
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,101 @@
|
|
|
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, makeRequest, parseResult, getAuditLogs } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let consoleSpy;
|
|
14
|
+
const envBackup = {};
|
|
15
|
+
|
|
16
|
+
function saveEnv(...keys) {
|
|
17
|
+
keys.forEach(k => { envBackup[k] = process.env[k]; });
|
|
18
|
+
}
|
|
19
|
+
function restoreEnv() {
|
|
20
|
+
Object.entries(envBackup).forEach(([k, v]) => {
|
|
21
|
+
if (v === undefined) delete process.env[k];
|
|
22
|
+
else process.env[k] = v;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
fetch.mockReset();
|
|
28
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
29
|
+
saveEnv('WP_READ_ONLY');
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
consoleSpy.mockRestore();
|
|
33
|
+
restoreEnv();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ── Mock data ──
|
|
37
|
+
|
|
38
|
+
const mockSettings = {
|
|
39
|
+
title: 'My Site',
|
|
40
|
+
description: 'Just another WordPress site',
|
|
41
|
+
url: 'https://test.example.com',
|
|
42
|
+
email: 'admin@test.example.com',
|
|
43
|
+
timezone_string: 'Europe/Brussels',
|
|
44
|
+
date_format: 'Y-m-d',
|
|
45
|
+
time_format: 'H:i',
|
|
46
|
+
language: 'en_US',
|
|
47
|
+
posts_per_page: 10,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// =========================================================================
|
|
51
|
+
// wp_get_site_options
|
|
52
|
+
// =========================================================================
|
|
53
|
+
|
|
54
|
+
describe('wp_get_site_options', () => {
|
|
55
|
+
it('returns all options when no keys parameter', async () => {
|
|
56
|
+
fetch.mockResolvedValue(mockSuccess(mockSettings));
|
|
57
|
+
const data = parseResult(await call('wp_get_site_options'));
|
|
58
|
+
expect(data.title).toBe('My Site');
|
|
59
|
+
expect(data.description).toBe('Just another WordPress site');
|
|
60
|
+
expect(data.timezone_string).toBe('Europe/Brussels');
|
|
61
|
+
expect(Object.keys(data).length).toBe(Object.keys(mockSettings).length);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('filters to requested keys only', async () => {
|
|
65
|
+
fetch.mockResolvedValue(mockSuccess(mockSettings));
|
|
66
|
+
const data = parseResult(await call('wp_get_site_options', { keys: ['title', 'language'] }));
|
|
67
|
+
expect(data.title).toBe('My Site');
|
|
68
|
+
expect(data.language).toBe('en_US');
|
|
69
|
+
expect(Object.keys(data)).toEqual(['title', 'language']);
|
|
70
|
+
expect(data.email).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('handles 403 (insufficient permissions)', async () => {
|
|
74
|
+
mockError(403, '{"code":"rest_forbidden","message":"Sorry, you are not allowed to manage options."}');
|
|
75
|
+
const res = await call('wp_get_site_options');
|
|
76
|
+
expect(res.isError).toBe(true);
|
|
77
|
+
expect(res.content[0].text).toContain('403');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('logs correct audit format to stderr', async () => {
|
|
81
|
+
fetch.mockResolvedValue(mockSuccess(mockSettings));
|
|
82
|
+
await call('wp_get_site_options', { keys: ['title'] });
|
|
83
|
+
const logs = getAuditLogs();
|
|
84
|
+
const entry = logs.find(l => l.tool === 'wp_get_site_options');
|
|
85
|
+
expect(entry).toBeDefined();
|
|
86
|
+
expect(entry.action).toBe('read_options');
|
|
87
|
+
expect(entry.status).toBe('success');
|
|
88
|
+
expect(typeof entry.latency_ms).toBe('number');
|
|
89
|
+
expect(entry.params.keys_requested).toBe(1);
|
|
90
|
+
expect(entry.params.keys_returned).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('is NOT blocked by WP_READ_ONLY=true', async () => {
|
|
94
|
+
process.env.WP_READ_ONLY = 'true';
|
|
95
|
+
fetch.mockResolvedValue(mockSuccess(mockSettings));
|
|
96
|
+
const res = await call('wp_get_site_options');
|
|
97
|
+
expect(res.isError).toBeUndefined();
|
|
98
|
+
const data = parseResult(res);
|
|
99
|
+
expect(data.title).toBe('My Site');
|
|
100
|
+
});
|
|
101
|
+
});
|