@gravitykit/block-mcp 2.0.0-beta → 2.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 (53) hide show
  1. package/README.md +34 -7
  2. package/dist/index.cjs +2556 -967
  3. package/package.json +12 -4
  4. package/src/__tests__/coerce.test.ts +57 -0
  5. package/src/__tests__/fixtures/error-envelopes.ts +19 -7
  6. package/src/__tests__/integration/dual-storage.test.ts +22 -26
  7. package/src/__tests__/integration/error-envelopes.test.ts +2 -2
  8. package/src/__tests__/integration/posts-terms-media.test.ts +271 -0
  9. package/src/__tests__/integration/read-discovery.test.ts +313 -0
  10. package/src/__tests__/integration/upload-roundtrip.test.ts +54 -0
  11. package/src/__tests__/integration/write-surface.test.ts +603 -0
  12. package/src/__tests__/integration/yoast-bridge.test.ts +39 -0
  13. package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
  14. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
  15. package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
  16. package/src/__tests__/tools/posts/update_post.test.ts +14 -0
  17. package/src/__tests__/tools/read/get_block.test.ts +35 -0
  18. package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
  19. package/src/__tests__/tools/read/pagination.test.ts +65 -0
  20. package/src/__tests__/tools/write/delete_block.test.ts +18 -0
  21. package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
  22. package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
  23. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
  24. package/src/__tests__/tools/write/update_block.test.ts +16 -0
  25. package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
  26. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
  27. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
  28. package/src/__tests__/unit/client/ref-endpoints.test.ts +3 -6
  29. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
  30. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +101 -30
  31. package/src/__tests__/unit/instructions.test.ts +3 -3
  32. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
  33. package/src/__tests__/unit/rest-url.test.ts +23 -0
  34. package/src/agent-guide.ts +85 -0
  35. package/src/client.ts +104 -40
  36. package/src/coerce.ts +41 -0
  37. package/src/config.ts +96 -0
  38. package/src/connect.ts +123 -38
  39. package/src/enrichers.ts +6 -2
  40. package/src/error-translator.ts +63 -11
  41. package/src/index.ts +49 -79
  42. package/src/instructions.ts +3 -3
  43. package/src/preferences.ts +56 -43
  44. package/src/rest-url.ts +18 -0
  45. package/src/tools/discovery.ts +10 -14
  46. package/src/tools/mutate.ts +21 -20
  47. package/src/tools/patterns.ts +3 -2
  48. package/src/tools/posts.ts +26 -26
  49. package/src/tools/read.ts +23 -6
  50. package/src/tools/write.ts +37 -36
  51. package/src/tools/yoast.ts +30 -31
  52. package/src/types.ts +45 -31
  53. package/src/validate-args.ts +109 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravitykit/block-mcp",
3
- "version": "2.0.0-beta",
3
+ "version": "2.1.0",
4
4
  "description": "MCP server for WordPress block-level content management with preference-aware editing",
5
5
  "main": "dist/index.cjs",
6
6
  "bin": {
@@ -16,6 +16,8 @@
16
16
  "test": "vitest run",
17
17
  "test:watch": "vitest",
18
18
  "test:integration": "vitest run --config vitest.integration.config.ts",
19
+ "test:docs": "playwright test --config tests/docs/playwright.config.ts",
20
+ "test:e2e": "playwright test --config tests/e2e/playwright.config.ts",
19
21
  "eval": "tsx tests/evals/lib/runner.ts",
20
22
  "eval:fixture-refresh": "tsx tests/evals/scripts/fetch-fixture.ts",
21
23
  "prepare": "npm run build",
@@ -41,18 +43,21 @@
41
43
  },
42
44
  "dependencies": {
43
45
  "@modelcontextprotocol/sdk": "^1.0.0",
44
- "axios": "^1.7.9"
46
+ "axios": "^1.16.0",
47
+ "form-data": "^4.0.1"
45
48
  },
46
49
  "devDependencies": {
47
50
  "@anthropic-ai/sdk": "^0.91.1",
51
+ "@playwright/test": "^1.49.0",
48
52
  "@shikijs/engine-javascript": "^4.0.2",
49
53
  "@shikijs/langs": "^4.0.2",
50
54
  "@types/node": "^22.0.0",
51
- "esbuild": "^0.27.3",
55
+ "esbuild": "^0.28.1",
56
+ "sharp": "^0.33.5",
52
57
  "shiki": "^4.0.2",
53
58
  "tsx": "^4.21.0",
54
59
  "typescript": "^5.7.0",
55
- "vitest": "^3.2.4"
60
+ "vitest": "^3.2.6"
56
61
  },
57
62
  "engines": {
58
63
  "node": ">=20.0.0"
@@ -66,5 +71,8 @@
66
71
  ],
67
72
  "publishConfig": {
68
73
  "access": "public"
74
+ },
75
+ "overrides": {
76
+ "esbuild": "$esbuild"
69
77
  }
70
78
  }
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { coercePostId } from '../coerce.js';
3
+
4
+ /**
5
+ * Unit tests for the shared coercePostId() helper.
6
+ *
7
+ * Every post_id tool (get_post_info, update_post, yoast_*) inherits this
8
+ * helper, so its "positive integer" contract must hold uniformly — a numeric
9
+ * string is accepted identically to the number, and a zero/negative/non-numeric
10
+ * value is rejected identically. The zero-string cases pin the fix for the gap
11
+ * where "0"/"00" parsed to 0 while the number 0 threw.
12
+ */
13
+ describe('coercePostId', () => {
14
+ it('returns undefined for absent values', () => {
15
+ expect(coercePostId(undefined, 't')).toBeUndefined();
16
+ expect(coercePostId(null, 't')).toBeUndefined();
17
+ });
18
+
19
+ it('accepts a positive integer number', () => {
20
+ expect(coercePostId(123, 't')).toBe(123);
21
+ });
22
+
23
+ it('coerces a positive numeric string', () => {
24
+ expect(coercePostId('123', 't')).toBe(123);
25
+ expect(coercePostId('042', 't')).toBe(42);
26
+ });
27
+
28
+ it('rejects the number 0 and negatives', () => {
29
+ expect(() => coercePostId(0, 't')).toThrow('post_id must be a positive integer');
30
+ expect(() => coercePostId(-1, 't')).toThrow('post_id must be a positive integer');
31
+ });
32
+
33
+ it('rejects zero-valued strings identically to the number 0', () => {
34
+ expect(() => coercePostId('0', 't')).toThrow('post_id must be a positive integer');
35
+ expect(() => coercePostId('00', 't')).toThrow('post_id must be a positive integer');
36
+ expect(() => coercePostId('000', 't')).toThrow('post_id must be a positive integer');
37
+ });
38
+
39
+ it('rejects floats and non-numeric strings', () => {
40
+ expect(() => coercePostId(1.5, 't')).toThrow('post_id must be a positive integer');
41
+ expect(() => coercePostId('12.5', 't')).toThrow('post_id must be a positive integer');
42
+ expect(() => coercePostId('abc', 't')).toThrow('post_id must be a positive integer');
43
+ expect(() => coercePostId('12abc', 't')).toThrow('post_id must be a positive integer');
44
+ });
45
+
46
+ it('rejects integers above Number.MAX_SAFE_INTEGER', () => {
47
+ // Beyond 2^53-1 the number can no longer represent the exact id, so a
48
+ // parsed/rounded value would target the wrong post.
49
+ expect(() => coercePostId(Number.MAX_SAFE_INTEGER + 1, 't')).toThrow('post_id must be a positive integer');
50
+ expect(() => coercePostId('9007199254740993', 't')).toThrow('post_id must be a positive integer');
51
+ expect(() => coercePostId('99999999999999999999', 't')).toThrow('post_id must be a positive integer');
52
+ });
53
+
54
+ it('prefixes the thrown error with the caller label', () => {
55
+ expect(() => coercePostId('x', 'update_post')).toThrow('update_post: post_id must be a positive integer');
56
+ });
57
+ });
@@ -43,19 +43,23 @@ export const ERROR_ENVELOPES: ErrorEnvelope[] = [
43
43
  { code: 'not_found', httpStatus: 404, data: null },
44
44
  { code: 'not_found_with_post', httpStatus: 404, data: { post_id: 77 } },
45
45
 
46
- // ── Precondition / concurrency (412) ────────────────────────────────────
47
- { code: 'stale_revision', httpStatus: 412, data: null },
46
+ // ── Precondition / concurrency (409/412) ────────────────────────────────
47
+ { code: 'stale_revision', httpStatus: 412, data: { current_revision: 88 } },
48
+ { code: 'edit_conflict', httpStatus: 409, data: null },
48
49
 
49
50
  // ── Validation (400) ────────────────────────────────────────────────────
50
51
  { code: 'legacy_block', httpStatus: 400, data: { block: 'ugb/text', suggested_replacement: 'core/paragraph' } },
52
+ { code: 'inner_html_required', httpStatus: 400, data: { block: 'core/paragraph', source_bound_attributes: ['content'] } },
53
+ { code: 'static_markup_stale_risk', httpStatus: 400, data: null },
51
54
  { code: 'dual_storage_requires_both', httpStatus: 400, data: { block_name: 'yoast/faq-block' } },
52
55
  { code: 'bound_attribute', httpStatus: 400, data: null },
53
56
  { code: 'batch_too_large', httpStatus: 400, data: null },
54
57
  { code: 'batch_validation_failed', httpStatus: 400, data: { errors: [{ index: 0, code: 'ref_stale' }] } },
55
58
  { code: 'empty_batch', httpStatus: 400, data: null },
56
- { code: 'block_depth_exceeded', httpStatus: 400, data: null },
59
+ { code: 'block_depth_exceeded', httpStatus: 400, data: { max_depth: 32, actual_depth: 33 } },
57
60
  { code: 'invalid_path', httpStatus: 400, data: { path: [0, 99] } },
58
61
  { code: 'invalid_ref', httpStatus: 400, data: null },
62
+ { code: 'invalid_index', httpStatus: 400, data: { flat_index: 12 } },
59
63
  { code: 'ref_not_top_level', httpStatus: 400, data: { ref: 'blk_nested' } },
60
64
  { code: 'invalid_op', httpStatus: 400, data: null },
61
65
  { code: 'invalid_block', httpStatus: 400, data: null },
@@ -70,9 +74,11 @@ export const ERROR_ENVELOPES: ErrorEnvelope[] = [
70
74
  { code: 'file_too_large', httpStatus: 400, data: null },
71
75
  { code: 'invalid_url', httpStatus: 400, data: null },
72
76
  { code: 'empty_pattern', httpStatus: 400, data: null },
77
+ { code: 'trash_disabled', httpStatus: 403, data: null },
73
78
 
74
79
  // ── Rate limit (429) ────────────────────────────────────────────────────
75
80
  { code: 'rate_limit_exceeded', httpStatus: 429, data: { post_id: 42 } },
81
+ { code: 'rate_limit_locked', httpStatus: 429, data: { post_id: 42 } },
76
82
  { code: 'scan_rate_limited', httpStatus: 429, data: null },
77
83
 
78
84
  // ── Upstream (502) ──────────────────────────────────────────────────────
@@ -97,19 +103,25 @@ export const TRANSLATED_CODES = new Set([
97
103
  'rest_cookie_invalid_nonce',
98
104
  'rest_authentication_required',
99
105
  'rest_post_invalid_id',
100
- 'invalid_post_id',
106
+ 'post_not_found',
101
107
  'not_found',
102
- 'gk_block_api_invalid_ref',
103
108
  'invalid_ref',
104
- 'path_not_found',
109
+ 'ref_stale',
105
110
  'invalid_path',
106
- 'path_out_of_bounds',
111
+ 'invalid_index',
107
112
  'legacy_block',
113
+ 'inner_html_required',
108
114
  'static_markup_stale_risk',
115
+ 'dual_storage_requires_both',
116
+ 'block_depth_exceeded',
117
+ 'edit_conflict',
118
+ 'stale_revision',
109
119
  'rate_limit_exceeded',
120
+ 'rate_limit_locked',
110
121
  'mixed_trash_payload',
111
122
  'invalid_post_type',
112
123
  'invalid_status',
124
+ 'trash_disabled',
113
125
  'invalid_url',
114
126
  'disallowed_mime',
115
127
  ]);
@@ -49,7 +49,7 @@ describe.skipIf(skip)('dual-storage enforcement (integration)', () => {
49
49
  }
50
50
  });
51
51
 
52
- it('update with only attributes on a dual-storage block returns 400 or 200 (requires scan)', async () => {
52
+ it('innerHTML-only update on a dual-storage block is rejected; attributes-only is allowed', async () => {
53
53
  if (!yoastInstalled || !dualBlockName) {
54
54
  console.log('[integration] No dual-storage block found (Yoast not installed or no dual blocks) — skipping');
55
55
  return;
@@ -78,46 +78,42 @@ describe.skipIf(skip)('dual-storage enforcement (integration)', () => {
78
78
  const blockIndex = inserted.inserted[0]?.index;
79
79
  expect(blockIndex).toBeGreaterThanOrEqual(0);
80
80
 
81
- // Try to update with ONLY attributes dual enforcement should reject it.
81
+ // The enforced direction (BLOCK-14): innerHTML ALONE desyncs the
82
+ // structured attributes (questions[] et al.) and must be rejected with
83
+ // dual_storage_requires_both. Attributes-only is allowed by design —
84
+ // the documented contract is "innerHTML-only is rejected".
82
85
  const credentials = Buffer.from(
83
86
  `${LIVE_ENV.user}:${LIVE_ENV.password}`
84
87
  ).toString('base64');
85
88
  const url = `${LIVE_ENV.url.replace(/\/+$/, '')}/wp-json/gk-block-api/v1/posts/${postId}/blocks/${blockIndex}`;
86
-
87
- const response = await axios.patch(
88
- url,
89
- { attributes: faqAttributes },
90
- {
89
+ const patch = (body: unknown) =>
90
+ axios.patch(url, body, {
91
91
  headers: {
92
92
  Authorization: `Basic ${credentials}`,
93
93
  'Content-Type': 'application/json',
94
94
  },
95
95
  timeout: 15_000,
96
96
  validateStatus: () => true,
97
- }
98
- );
97
+ });
99
98
 
100
- if (response.status === 400) {
101
- // Dual-storage enforcement is active — verify the error code.
102
- const body = response.data as Record<string, unknown>;
103
- expect(body.code).toBe('dual_storage_requires_both');
104
- expect(body.message).toBeTruthy();
105
- } else if (response.status === 200) {
106
- // The plugin returned a 200 because the storage-mode scan classifying
107
- // this block as `dual` had not been run on this site. Silently
108
- // logging-and-continuing turns the test green even though the
109
- // enforcement path was never exercised — the same outcome a real
110
- // regression that broke enforcement would produce. Fail loudly so
111
- // CI surfaces the missing precondition instead of papering over it.
99
+ const htmlOnly = await patch({ innerHTML: faqInnerHtml });
100
+ if (htmlOnly.status === 200) {
101
+ // A 200 here means the block is not classified dual on this site —
102
+ // the enforcement path was never exercised. Fail loudly so CI
103
+ // surfaces the missing precondition instead of papering over it.
112
104
  throw new Error(
113
105
  `[integration] dual-storage not enforced for ${dualBlockName} ` +
114
- '— call scan_storage_modes to classify the block as dual, then re-run this suite. ' +
115
- 'The 200 response means the precondition for this test is not met, not that the test passed.'
106
+ '— call scan_storage_modes (or add the block to the dual-storage list), then re-run.'
116
107
  );
117
- } else {
118
- // Any other status is unexpected.
119
- throw new Error(`Unexpected status ${response.status} from dual-storage update`);
120
108
  }
109
+ expect(htmlOnly.status).toBe(400);
110
+ const body = htmlOnly.data as Record<string, unknown>;
111
+ expect(body.code).toBe('dual_storage_requires_both');
112
+ expect(body.message).toBeTruthy();
113
+
114
+ // The permitted direction: attributes-only succeeds.
115
+ const attrsOnly = await patch({ attributes: faqAttributes });
116
+ expect(attrsOnly.status).toBe(200);
121
117
  });
122
118
  }, 45_000);
123
119
 
@@ -125,14 +125,14 @@ describe.skipIf(skip)('real REST error envelopes (integration)', () => {
125
125
  expect(result.message).toBeTruthy();
126
126
  });
127
127
 
128
- it('invalid_path: mutate with out-of-range path returns 400 + path error code', async () => {
128
+ it('invalid_path: mutate with out-of-range path returns 400 + invalid_path', async () => {
129
129
  const result = await rawRequest('post', `/posts/${livePostId}/mutate`, {
130
130
  op: 'update-attrs',
131
131
  path: [999, 999, 999],
132
132
  attributes: { content: 'never land' },
133
133
  });
134
134
  expect(result.status).toBe(400);
135
- expect(['invalid_path', 'path_not_found', 'path_out_of_bounds']).toContain(result.code);
135
+ expect(result.code).toBe('invalid_path');
136
136
  expect(result.message).toBeTruthy();
137
137
  });
138
138
 
@@ -0,0 +1,271 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { skipUnlessLive, makeLiveClient, withTestPost, LIVE_ENV } from './setup.js';
6
+ import type { CreatePostRequest, UpdatePostRequest } from '../../types.js';
7
+
8
+ /**
9
+ * Edge-case coverage for the post-lifecycle, terms, and media surfaces
10
+ * against a live WordPress — every accepted argument plus the guard rails
11
+ * (allow-list, status enum, mutual exclusion, trash gate, SSRF, MIME
12
+ * allow-list, filename hygiene).
13
+ */
14
+
15
+ interface WpErr {
16
+ message?: string;
17
+ wpCode?: string;
18
+ wpStatus?: number;
19
+ }
20
+
21
+ async function grab(promise: Promise<unknown>): Promise<WpErr> {
22
+ try {
23
+ await promise;
24
+ return {};
25
+ } catch (e) {
26
+ return e as WpErr;
27
+ }
28
+ }
29
+
30
+ // 1x1 transparent PNG.
31
+ const PNG_B64 =
32
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
33
+
34
+ describe.skipIf(skipUnlessLive())('integration: create_post argument surface', () => {
35
+ it('content and blocks are mutually exclusive', async () => {
36
+ const client = makeLiveClient();
37
+ const err = await grab(
38
+ client.createPost({
39
+ title: `${LIVE_ENV.prefix} conflict`,
40
+ content: '<p>html</p>',
41
+ blocks: [{ name: 'core/paragraph', innerHTML: '<p>blocks</p>' }],
42
+ } as CreatePostRequest),
43
+ );
44
+ expect(err.message).toMatch(/mutually exclusive|content.*blocks/i);
45
+ });
46
+
47
+ it('invalid status is rejected', async () => {
48
+ const client = makeLiveClient();
49
+ const err = await grab(
50
+ client.createPost({
51
+ title: `${LIVE_ENV.prefix} bad status`,
52
+ status: 'banana',
53
+ } as unknown as CreatePostRequest),
54
+ );
55
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
56
+ });
57
+
58
+ it('a post type outside the allow-list is rejected', async () => {
59
+ const client = makeLiveClient();
60
+ const err = await grab(
61
+ client.createPost({
62
+ title: `${LIVE_ENV.prefix} bad type`,
63
+ post_type: 'gk_definitely_not_allowed',
64
+ } as CreatePostRequest),
65
+ );
66
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
67
+ });
68
+
69
+ it('slug + excerpt persist on create', async () => {
70
+ const client = makeLiveClient();
71
+ const slug = `edge-slug-${process.pid}`;
72
+ const created = await client.createPost({
73
+ title: `${LIVE_ENV.prefix} slugged`,
74
+ status: 'draft',
75
+ slug,
76
+ excerpt: 'EDGE-EXCERPT',
77
+ blocks: [{ name: 'core/paragraph', innerHTML: '<p>x</p>' }],
78
+ } as CreatePostRequest);
79
+ try {
80
+ const info = (await client.getPostInfo({ post_id: created.id })) as { slug?: string };
81
+ expect(info.slug).toBe(slug);
82
+ // Slug+post_type lookup resolves back to the same post.
83
+ const bySlug = (await client.getPostInfo({ slug, post_type: 'post' })) as { post_id?: number };
84
+ expect(bySlug.post_id).toBe(created.id);
85
+ } finally {
86
+ // Trash may be gated; draft test content is swept by title prefix.
87
+ await grab(client.updatePost(created.id, { status: 'trash' } as UpdatePostRequest));
88
+ }
89
+ });
90
+ });
91
+
92
+ describe.skipIf(skipUnlessLive())('integration: update_post argument surface', () => {
93
+ it('title / slug / excerpt update together', async () => {
94
+ const client = makeLiveClient();
95
+ await withTestPost(client, async (postId) => {
96
+ const res = await client.updatePost(postId, {
97
+ title: `${LIVE_ENV.prefix} retitled`,
98
+ slug: `edge-retitle-${process.pid}`,
99
+ excerpt: 'EDGE-NEW-EXCERPT',
100
+ });
101
+ expect(res.success).toBe(true);
102
+ const info = (await client.getPostInfo({ post_id: postId })) as {
103
+ title?: string;
104
+ slug?: string;
105
+ };
106
+ expect(info.title).toContain('retitled');
107
+ expect(info.slug).toBe(`edge-retitle-${process.pid}`);
108
+ });
109
+ });
110
+
111
+ it('status transition draft → publish → draft round-trips', async () => {
112
+ const client = makeLiveClient();
113
+ await withTestPost(client, async (postId) => {
114
+ const publish = await client.updatePost(postId, { status: 'publish' });
115
+ expect(publish.success).toBe(true);
116
+
117
+ const back = await client.updatePost(postId, { status: 'draft' });
118
+ expect(back.success).toBe(true);
119
+ });
120
+ });
121
+
122
+ it('trash combined with other fields is rejected (mixed payload or gate)', async () => {
123
+ const client = makeLiveClient();
124
+ await withTestPost(client, async (postId) => {
125
+ const err = await grab(
126
+ client.updatePost(postId, { status: 'trash', title: 'sneaky' } as UpdatePostRequest),
127
+ );
128
+ // Whichever guard fires first — the trash gate (when disabled) or the
129
+ // mixed-payload guard — the write must NOT go through silently.
130
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
131
+ const info = (await client.getPostInfo({ post_id: postId })) as { title?: string };
132
+ expect(info.title).not.toBe('sneaky');
133
+ });
134
+ });
135
+ });
136
+
137
+ describe.skipIf(skipUnlessLive())('integration: list_terms argument surface', () => {
138
+ it('invalid taxonomy fails loudly', async () => {
139
+ const client = makeLiveClient();
140
+ const err = await grab(client.listTerms({ taxonomy: 'gk_no_such_tax' }));
141
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
142
+ });
143
+
144
+ it('per_page is honored and capped', async () => {
145
+ const client = makeLiveClient();
146
+ const res = (await client.listTerms({ taxonomy: 'category', per_page: 1 })) as {
147
+ terms?: unknown[];
148
+ };
149
+ expect((res.terms ?? []).length).toBeLessThanOrEqual(1);
150
+
151
+ const err = await grab(client.listTerms({ taxonomy: 'category', per_page: 500 }));
152
+ // Either rejected outright or clamped — never an unbounded dump.
153
+ if (!err.wpStatus) {
154
+ const all = (await client.listTerms({ taxonomy: 'category', per_page: 500 })) as {
155
+ terms?: unknown[];
156
+ };
157
+ expect((all.terms ?? []).length).toBeLessThanOrEqual(200);
158
+ }
159
+ });
160
+
161
+ it('orderby/order + search + slug filters apply', async () => {
162
+ const client = makeLiveClient();
163
+ const res = (await client.listTerms({
164
+ taxonomy: 'category',
165
+ orderby: 'name',
166
+ order: 'desc',
167
+ per_page: 5,
168
+ } as never)) as { terms?: Array<{ name: string; slug: string }> };
169
+ const names = (res.terms ?? []).map((t) => t.name.toLowerCase());
170
+ const sorted = [...names].sort().reverse();
171
+ expect(names).toEqual(sorted);
172
+
173
+ if (res.terms?.length) {
174
+ const target = res.terms[0];
175
+ const bySlug = (await client.listTerms({ taxonomy: 'category', slug: target.slug } as never)) as {
176
+ terms?: Array<{ slug: string }>;
177
+ };
178
+ expect(bySlug.terms?.every((t) => t.slug === target.slug)).toBe(true);
179
+ }
180
+ });
181
+ });
182
+
183
+ describe.skipIf(skipUnlessLive())('integration: upload_media guard rails', () => {
184
+ it('SSRF: url mode pointing at a private/reserved address is blocked', async () => {
185
+ const client = makeLiveClient();
186
+ for (const url of ['http://127.0.0.1:9/x.png', 'http://169.254.169.254/latest/meta-data.png']) {
187
+ const err = await grab(client.uploadMedia({ url, title: `${LIVE_ENV.prefix} ssrf` }));
188
+ expect(err.wpStatus, url).toBeGreaterThanOrEqual(400);
189
+ expect(`${err.wpCode}`, url).toMatch(/invalid_url|url_fetch_failed/);
190
+ }
191
+ });
192
+
193
+ it('disallowed MIME (a .php "image") is rejected', async () => {
194
+ const client = makeLiveClient();
195
+ const err = await grab(
196
+ client.uploadMedia({
197
+ data_base64: PNG_B64,
198
+ filename: 'evil.php',
199
+ title: `${LIVE_ENV.prefix} mime`,
200
+ }),
201
+ );
202
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
203
+ expect(`${err.wpCode}`).toMatch(/disallowed_mime|invalid_filename|sideload_failed/);
204
+ });
205
+
206
+ it('path traversal in filename does not escape the uploads dir', async () => {
207
+ const client = makeLiveClient();
208
+ const result = await grab(
209
+ client.uploadMedia({
210
+ data_base64: PNG_B64,
211
+ filename: '../../traversal.png',
212
+ title: `${LIVE_ENV.prefix} traversal`,
213
+ }),
214
+ );
215
+ if (!result.wpStatus) {
216
+ // Accepted → the stored file must be sanitized to a basename.
217
+ const ok = (await client.uploadMedia({
218
+ data_base64: PNG_B64,
219
+ filename: '../../traversal.png',
220
+ title: `${LIVE_ENV.prefix} traversal2`,
221
+ })) as { source_url?: string; url?: string };
222
+ const storedUrl = String(ok.source_url ?? ok.url);
223
+ expect(storedUrl).not.toContain('..');
224
+ expect(storedUrl).toMatch(/traversal[^/]*\.png$/);
225
+ } else {
226
+ expect(`${result.wpCode}`).toMatch(/invalid_filename|disallowed_mime/);
227
+ }
228
+ });
229
+
230
+ it('invalid base64 payload fails loudly', async () => {
231
+ const client = makeLiveClient();
232
+ const err = await grab(
233
+ client.uploadMedia({
234
+ data_base64: '!!!not-base64!!!',
235
+ filename: 'bad.png',
236
+ title: `${LIVE_ENV.prefix} badb64`,
237
+ }),
238
+ );
239
+ expect(err.wpStatus).toBeGreaterThanOrEqual(400);
240
+ });
241
+
242
+ it('path mode with a nonexistent local file throws client-side (nothing sent)', async () => {
243
+ const client = makeLiveClient();
244
+ const err = await grab(
245
+ client.uploadMedia({ path: path.join(os.tmpdir(), 'gk-definitely-missing.png') }),
246
+ );
247
+ expect(err.message).toMatch(/ENOENT|no such file/i);
248
+ });
249
+
250
+ it('metadata args (title, alt_text, caption, description, post_id) ride along', async () => {
251
+ const client = makeLiveClient();
252
+ await withTestPost(client, async (postId) => {
253
+ const tmp = path.join(os.tmpdir(), `gk-edge-meta-${process.pid}.png`);
254
+ fs.writeFileSync(tmp, Buffer.from(PNG_B64, 'base64'));
255
+ try {
256
+ const res = (await client.uploadMedia({
257
+ path: tmp,
258
+ title: `${LIVE_ENV.prefix} meta upload`,
259
+ alt_text: 'EDGE-ALT',
260
+ caption: 'EDGE-CAPTION',
261
+ description: 'EDGE-DESC',
262
+ post_id: postId,
263
+ })) as { id?: number; source_url?: string; url?: string };
264
+ expect(res.id).toBeGreaterThan(0);
265
+ expect(String(res.source_url ?? res.url)).toMatch(/\.png$/);
266
+ } finally {
267
+ fs.rmSync(tmp, { force: true });
268
+ }
269
+ });
270
+ });
271
+ });