@adsim/wordpress-mcp-server 4.5.0 → 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 +92 -11
- package/dxt/manifest.json +17 -6
- package/index.js +327 -164
- 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 +136 -0
- package/tests/unit/tools/outputCompression.test.js +342 -0
- package/tests/unit/tools/site.test.js +3 -1
- package/tests/unit/tools/siteOptions.test.js +101 -0
|
@@ -0,0 +1,151 @@
|
|
|
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, getFilteredTools, pluginRegistry } from '../../index.js';
|
|
7
|
+
import { mockSuccess, makeRequest, parseResult } from '../helpers/mockWpRequest.js';
|
|
8
|
+
import { acfAdapter } from '../../src/plugins/adapters/acf/acfAdapter.js';
|
|
9
|
+
|
|
10
|
+
function call(name, args = {}) {
|
|
11
|
+
return handleToolCall(makeRequest(name, args));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let consoleSpy;
|
|
15
|
+
const envBackup = {};
|
|
16
|
+
|
|
17
|
+
function saveEnv(...keys) {
|
|
18
|
+
keys.forEach(k => { envBackup[k] = process.env[k]; });
|
|
19
|
+
}
|
|
20
|
+
function restoreEnv() {
|
|
21
|
+
Object.entries(envBackup).forEach(([k, v]) => {
|
|
22
|
+
if (v === undefined) delete process.env[k];
|
|
23
|
+
else process.env[k] = v;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
fetch.mockReset();
|
|
29
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
30
|
+
saveEnv('WP_DISABLE_PLUGIN_LAYERS');
|
|
31
|
+
delete process.env.WP_DISABLE_PLUGIN_LAYERS;
|
|
32
|
+
|
|
33
|
+
// Reset registry to clean state
|
|
34
|
+
pluginRegistry.active.clear();
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
consoleSpy.mockRestore();
|
|
38
|
+
restoreEnv();
|
|
39
|
+
pluginRegistry.active.clear();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── Helper: simulate ACF detected ──
|
|
43
|
+
|
|
44
|
+
function simulateAcfDetected() {
|
|
45
|
+
pluginRegistry.active.set('acf', {
|
|
46
|
+
id: 'acf',
|
|
47
|
+
namespace: 'acf/v3',
|
|
48
|
+
version: null,
|
|
49
|
+
tools: acfAdapter.getTools(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// =========================================================================
|
|
54
|
+
// Tests
|
|
55
|
+
// =========================================================================
|
|
56
|
+
|
|
57
|
+
describe('Plugin Layer integration', () => {
|
|
58
|
+
|
|
59
|
+
it('listTools includes acf_get_fields when ACF is detected', () => {
|
|
60
|
+
simulateAcfDetected();
|
|
61
|
+
const tools = getFilteredTools();
|
|
62
|
+
const names = tools.map(t => t.name);
|
|
63
|
+
expect(names).toContain('acf_get_fields');
|
|
64
|
+
expect(names).toContain('acf_list_field_groups');
|
|
65
|
+
expect(names).toContain('acf_get_field_group');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('listTools does NOT include ACF tools when WP_DISABLE_PLUGIN_LAYERS=true', () => {
|
|
69
|
+
simulateAcfDetected();
|
|
70
|
+
process.env.WP_DISABLE_PLUGIN_LAYERS = 'true';
|
|
71
|
+
const tools = getFilteredTools();
|
|
72
|
+
const names = tools.map(t => t.name);
|
|
73
|
+
expect(names).not.toContain('acf_get_fields');
|
|
74
|
+
expect(names).not.toContain('acf_list_field_groups');
|
|
75
|
+
expect(names).not.toContain('acf_get_field_group');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('callTool("acf_get_fields") routes to the adapter handler', async () => {
|
|
79
|
+
simulateAcfDetected();
|
|
80
|
+
|
|
81
|
+
// Mock the ACF REST API response
|
|
82
|
+
fetch.mockResolvedValue(mockSuccess({ acf: { title: 'Hello', price: 42 } }));
|
|
83
|
+
|
|
84
|
+
const res = await call('acf_get_fields', { id: 10, post_type: 'posts' });
|
|
85
|
+
expect(res.isError).toBeUndefined();
|
|
86
|
+
const data = parseResult(res);
|
|
87
|
+
expect(data.acf.title).toBe('Hello');
|
|
88
|
+
expect(data.acf.price).toBe(42);
|
|
89
|
+
expect(data.id).toBe(10);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('callTool("wp_list_posts") continues to work normally', async () => {
|
|
93
|
+
simulateAcfDetected();
|
|
94
|
+
|
|
95
|
+
const mockPost = { id: 1, title: { rendered: 'Test Post' }, slug: 'test', status: 'publish', date: '2024-01-01', link: 'https://test.example.com/test', excerpt: { rendered: 'excerpt' }, author: 1, categories: [1], tags: [] };
|
|
96
|
+
fetch.mockResolvedValue(mockSuccess([mockPost]));
|
|
97
|
+
|
|
98
|
+
const res = await call('wp_list_posts', { per_page: 1 });
|
|
99
|
+
expect(res.isError).toBeUndefined();
|
|
100
|
+
const data = parseResult(res);
|
|
101
|
+
expect(data.posts[0].title).toBe('Test Post');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('callTool with unknown tool returns error', async () => {
|
|
105
|
+
const res = await call('totally_fake_tool', {});
|
|
106
|
+
expect(res.isError).toBe(true);
|
|
107
|
+
expect(res.content[0].text).toContain('Unknown tool');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('wp_site_info includes plugin_layer in response', async () => {
|
|
111
|
+
simulateAcfDetected();
|
|
112
|
+
|
|
113
|
+
// wp_site_info makes 3 sequential fetch calls
|
|
114
|
+
const siteInfo = { name: 'Test', description: 'A test', url: 'https://test.example.com', gmt_offset: 1, timezone_string: 'UTC' };
|
|
115
|
+
const userMe = { id: 1, name: 'Admin', slug: 'admin', roles: ['administrator'] };
|
|
116
|
+
const postTypes = { post: { slug: 'post', name: 'Posts', rest_base: 'posts' } };
|
|
117
|
+
mockSuccess(siteInfo);
|
|
118
|
+
mockSuccess(userMe);
|
|
119
|
+
mockSuccess(postTypes);
|
|
120
|
+
|
|
121
|
+
const res = await call('wp_site_info');
|
|
122
|
+
const data = parseResult(res);
|
|
123
|
+
expect(data.plugin_layer).toBeDefined();
|
|
124
|
+
expect(data.plugin_layer.active).toContain('acf');
|
|
125
|
+
expect(data.plugin_layer.disabled_by_env).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('plugin_layer.active is empty when no plugins detected', async () => {
|
|
129
|
+
// Registry is empty (cleared in beforeEach)
|
|
130
|
+
const siteInfo = { name: 'Test', description: 'A test', url: 'https://test.example.com', gmt_offset: 1, timezone_string: 'UTC' };
|
|
131
|
+
const userMe = { id: 1, name: 'Admin', slug: 'admin', roles: ['administrator'] };
|
|
132
|
+
const postTypes = { post: { slug: 'post', name: 'Posts', rest_base: 'posts' } };
|
|
133
|
+
mockSuccess(siteInfo);
|
|
134
|
+
mockSuccess(userMe);
|
|
135
|
+
mockSuccess(postTypes);
|
|
136
|
+
|
|
137
|
+
const res = await call('wp_site_info');
|
|
138
|
+
const data = parseResult(res);
|
|
139
|
+
expect(data.plugin_layer.active).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('none of the 86 core tools are overwritten by plugin tools', () => {
|
|
143
|
+
simulateAcfDetected();
|
|
144
|
+
const tools = getFilteredTools();
|
|
145
|
+
const names = tools.map(t => t.name);
|
|
146
|
+
|
|
147
|
+
// Check no duplicates
|
|
148
|
+
const unique = new Set(names);
|
|
149
|
+
expect(unique.size).toBe(names.length);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { acfAdapter } from '../../../../src/plugins/adapters/acf/acfAdapter.js';
|
|
3
|
+
import { validateAdapter } from '../../../../src/plugins/IPluginAdapter.js';
|
|
4
|
+
|
|
5
|
+
let consoleSpy;
|
|
6
|
+
const envBackup = {};
|
|
7
|
+
|
|
8
|
+
function saveEnv(...keys) {
|
|
9
|
+
keys.forEach(k => { envBackup[k] = process.env[k]; });
|
|
10
|
+
}
|
|
11
|
+
function restoreEnv() {
|
|
12
|
+
Object.entries(envBackup).forEach(([k, v]) => {
|
|
13
|
+
if (v === undefined) delete process.env[k];
|
|
14
|
+
else process.env[k] = v;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
20
|
+
saveEnv('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
|
+
// ── Mock data ──
|
|
48
|
+
|
|
49
|
+
const mockAcfFields = {
|
|
50
|
+
acf: {
|
|
51
|
+
hero_title: 'Welcome to our site',
|
|
52
|
+
hero_subtitle: 'The best place',
|
|
53
|
+
price: 29.99,
|
|
54
|
+
show_banner: true,
|
|
55
|
+
gallery: [{ url: 'https://example.com/img1.jpg' }, { url: 'https://example.com/img2.jpg' }],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const mockFieldGroups = [
|
|
60
|
+
{
|
|
61
|
+
id: 101,
|
|
62
|
+
title: { rendered: 'Hero Section' },
|
|
63
|
+
fields: [{ name: 'hero_title' }, { name: 'hero_subtitle' }],
|
|
64
|
+
location: [[{ param: 'post_type', operator: '==', value: 'page' }]],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 102,
|
|
68
|
+
title: { rendered: 'Product Info' },
|
|
69
|
+
fields: [{ name: 'price' }, { name: 'sku' }],
|
|
70
|
+
location: [[{ param: 'post_type', operator: '==', value: 'product' }]],
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const mockFieldGroupDetail = {
|
|
75
|
+
id: 101,
|
|
76
|
+
title: { rendered: 'Hero Section' },
|
|
77
|
+
fields: [
|
|
78
|
+
{ key: 'field_abc', name: 'hero_title', type: 'text', label: 'Hero Title' },
|
|
79
|
+
{ key: 'field_def', name: 'hero_subtitle', type: 'text', label: 'Hero Subtitle' },
|
|
80
|
+
],
|
|
81
|
+
location: [[{ param: 'post_type', operator: '==', value: 'page' }]],
|
|
82
|
+
active: true,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// =========================================================================
|
|
86
|
+
// Tests
|
|
87
|
+
// =========================================================================
|
|
88
|
+
|
|
89
|
+
describe('ACF Adapter', () => {
|
|
90
|
+
|
|
91
|
+
// 1. validateAdapter
|
|
92
|
+
it('passes validateAdapter without errors', () => {
|
|
93
|
+
const { valid, errors } = validateAdapter(acfAdapter);
|
|
94
|
+
expect(valid).toBe(true);
|
|
95
|
+
expect(errors).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// 2. acf_get_fields — success
|
|
99
|
+
it('acf_get_fields returns ACF fields for a post', async () => {
|
|
100
|
+
const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
|
|
101
|
+
const result = await tool.handler({ id: 42, post_type: 'posts' }, mockApi(mockAcfFields));
|
|
102
|
+
const data = parseResult(result);
|
|
103
|
+
expect(data.id).toBe(42);
|
|
104
|
+
expect(data.acf.hero_title).toBe('Welcome to our site');
|
|
105
|
+
expect(data.acf.price).toBe(29.99);
|
|
106
|
+
expect(data.fields_count).toBe(5);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 3. acf_get_fields — fields filter
|
|
110
|
+
it('acf_get_fields filters to requested keys only', async () => {
|
|
111
|
+
const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
|
|
112
|
+
const result = await tool.handler(
|
|
113
|
+
{ id: 42, fields: ['hero_title', 'price'] },
|
|
114
|
+
mockApi(mockAcfFields)
|
|
115
|
+
);
|
|
116
|
+
const data = parseResult(result);
|
|
117
|
+
expect(Object.keys(data.acf)).toEqual(['hero_title', 'price']);
|
|
118
|
+
expect(data.acf.hero_subtitle).toBeUndefined();
|
|
119
|
+
expect(data.fields_count).toBe(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// 4. acf_get_fields — contextGuard truncation
|
|
123
|
+
it('acf_get_fields applies contextGuard on large responses', async () => {
|
|
124
|
+
// Generate a response > 30000 chars
|
|
125
|
+
const bigFields = {};
|
|
126
|
+
for (let i = 0; i < 500; i++) {
|
|
127
|
+
bigFields[`field_${i}`] = 'x'.repeat(100);
|
|
128
|
+
}
|
|
129
|
+
const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
|
|
130
|
+
const result = await tool.handler(
|
|
131
|
+
{ id: 1, mode: 'compact' },
|
|
132
|
+
mockApi({ acf: bigFields })
|
|
133
|
+
);
|
|
134
|
+
const data = parseResult(result);
|
|
135
|
+
expect(data._truncated).toBe(true);
|
|
136
|
+
expect(data._original_size).toBeGreaterThan(30000);
|
|
137
|
+
expect(data._warning).toContain('truncated');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// 5. acf_get_fields — 404
|
|
141
|
+
it('acf_get_fields propagates 404 error', async () => {
|
|
142
|
+
const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
|
|
143
|
+
await expect(
|
|
144
|
+
tool.handler({ id: 99999 }, mockApi(null, true))
|
|
145
|
+
).rejects.toThrow('404');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// 6. acf_list_field_groups — success
|
|
149
|
+
it('acf_list_field_groups returns the list', async () => {
|
|
150
|
+
const tool = acfAdapter.getTools().find(t => t.name === 'acf_list_field_groups');
|
|
151
|
+
const result = await tool.handler({}, mockApi(mockFieldGroups));
|
|
152
|
+
const data = parseResult(result);
|
|
153
|
+
expect(data.total).toBe(2);
|
|
154
|
+
expect(data.field_groups[0].title).toBe('Hero Section');
|
|
155
|
+
expect(data.field_groups[0].fields).toEqual(['hero_title', 'hero_subtitle']);
|
|
156
|
+
expect(data.field_groups[1].id).toBe(102);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// 7. acf_get_field_group — success
|
|
160
|
+
it('acf_get_field_group returns full details', async () => {
|
|
161
|
+
const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_field_group');
|
|
162
|
+
const result = await tool.handler({ id: 101 }, mockApi(mockFieldGroupDetail));
|
|
163
|
+
const data = parseResult(result);
|
|
164
|
+
expect(data.id).toBe(101);
|
|
165
|
+
expect(data.fields).toHaveLength(2);
|
|
166
|
+
expect(data.fields[0].name).toBe('hero_title');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// 8. acf_get_field_group — 404
|
|
170
|
+
it('acf_get_field_group propagates 404 error', async () => {
|
|
171
|
+
const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_field_group');
|
|
172
|
+
await expect(
|
|
173
|
+
tool.handler({ id: 99999 }, mockApi(null, true))
|
|
174
|
+
).rejects.toThrow('404');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 9. None of the tools are write tools — not blocked by WP_READ_ONLY
|
|
178
|
+
it('none of the 3 tools appear in core writeTools list', () => {
|
|
179
|
+
const writeTools = [
|
|
180
|
+
'wp_create_post', 'wp_update_post', 'wp_delete_post', 'wp_create_page',
|
|
181
|
+
'wp_update_page', 'wp_upload_media', 'wp_create_comment',
|
|
182
|
+
'wp_create_taxonomy_term', 'wp_update_seo_meta', 'wp_activate_plugin',
|
|
183
|
+
'wp_deactivate_plugin', 'wp_restore_revision', 'wp_delete_revision',
|
|
184
|
+
'wp_submit_for_review', 'wp_approve_post', 'wp_reject_post',
|
|
185
|
+
'wc_update_product', 'wc_update_stock', 'wc_update_order_status',
|
|
186
|
+
];
|
|
187
|
+
const acfToolNames = acfAdapter.getTools().map(t => t.name);
|
|
188
|
+
for (const n of acfToolNames) {
|
|
189
|
+
expect(writeTools).not.toContain(n);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// 10. Audit log format
|
|
194
|
+
it('acf_get_fields logs correct audit format to stderr', async () => {
|
|
195
|
+
const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
|
|
196
|
+
await tool.handler({ id: 42, post_type: 'posts' }, mockApi(mockAcfFields));
|
|
197
|
+
const logs = getAuditLogs();
|
|
198
|
+
const entry = logs.find(l => l.tool === 'acf_get_fields');
|
|
199
|
+
expect(entry).toBeDefined();
|
|
200
|
+
expect(entry.action).toBe('read_acf_fields');
|
|
201
|
+
expect(entry.target).toBe(42);
|
|
202
|
+
expect(entry.status).toBe('success');
|
|
203
|
+
expect(typeof entry.latency_ms).toBe('number');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -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
|
+
});
|