PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / tests / Integration / QuickEdit / Controllers / SaveControllerTest.php

SaveControllerTest.php in Extendify 3.1.0, at tests/Integration/QuickEdit/Controllers/SaveControllerTest.php

1,252 lines 54.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Extendify\Tests\Integration\QuickEdit\Controllers;
4
5 use Extendify\QuickEdit\Controllers\SaveController;
6 use Extendify\QuickEdit\Schemas\Registry;
7 use ReflectionClass;
8 use WP_UnitTestCase;
9
10 class SaveControllerTest extends WP_UnitTestCase
11 {
12 public function setUp(): void
13 {
14 parent::setUp();
15 $this->resetRegistry();
16 Registry::init();
17 }
18
19 public function tearDown(): void
20 {
21 $this->resetRegistry();
22 delete_option('trp_settings');
23 unset($GLOBALS['TRP_LANGUAGE']);
24 parent::tearDown();
25 }
26
27 public function test_permission_callback_requires_admin_capability()
28 {
29 wp_set_current_user(0);
30 $this->assertFalse(SaveController::permissionCallback());
31
32 $editor = self::factory()->user->create(['role' => 'editor']);
33 wp_set_current_user($editor);
34 $this->assertFalse(SaveController::permissionCallback());
35
36 $admin = self::factory()->user->create(['role' => 'administrator']);
37 wp_set_current_user($admin);
38 $this->assertTrue(SaveController::permissionCallback());
39 }
40
41 public function test_missing_required_fields_returns_400()
42 {
43 $this->loginAsAdmin();
44 $res = SaveController::handleSave($this->jsonRequest([
45 'source' => ['kind' => 'post', 'id' => 1],
46 // no blockId, no blockType, no patches/rawBlock
47 ]));
48
49 $this->assertSame(400, $res->get_status());
50 $this->assertStringContainsString('required', $res->get_data()['error']);
51 }
52
53 public function test_unknown_block_type_with_patches_returns_400_no_schema()
54 {
55 $this->loginAsAdmin();
56 $postId = self::factory()->post->create([
57 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
58 ]);
59
60 $res = SaveController::handleSave($this->jsonRequest([
61 'source' => ['kind' => 'post', 'id' => $postId],
62 'blockId' => 1,
63 'blockType' => 'not/a-real-block',
64 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
65 ]));
66
67 $this->assertSame(400, $res->get_status());
68 $data = $res->get_data();
69 $this->assertSame('no schema registered for block type', $data['error']);
70 $this->assertSame('not/a-real-block', $data['blockType']);
71 }
72
73 public function test_post_not_found_returns_404()
74 {
75 $this->loginAsAdmin();
76 $res = SaveController::handleSave($this->jsonRequest([
77 'source' => ['kind' => 'post', 'id' => 99999999],
78 'blockId' => 1,
79 'blockType' => 'core/paragraph',
80 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
81 ]));
82
83 $this->assertSame(404, $res->get_status());
84 }
85
86 public function test_unknown_source_kind_returns_404()
87 {
88 $this->loginAsAdmin();
89 $res = SaveController::handleSave($this->jsonRequest([
90 'source' => ['kind' => 'bogus'],
91 'blockId' => 1,
92 'blockType' => 'core/paragraph',
93 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
94 ]));
95
96 $this->assertSame(404, $res->get_status());
97 $this->assertSame('unknown source kind', $res->get_data()['error']);
98 }
99
100 public function test_template_part_requires_partSlug()
101 {
102 $this->loginAsAdmin();
103 $res = SaveController::handleSave($this->jsonRequest([
104 'source' => ['kind' => 'template-part'],
105 'blockId' => 1,
106 'blockType' => 'core/paragraph',
107 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
108 ]));
109
110 $this->assertSame(404, $res->get_status());
111 $this->assertSame('template-part requires partSlug', $res->get_data()['error']);
112 }
113
114 public function test_subscriber_user_forbidden_per_source()
115 {
116 $sub = self::factory()->user->create(['role' => 'subscriber']);
117 wp_set_current_user($sub);
118 $postId = self::factory()->post->create([
119 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
120 ]);
121
122 $res = SaveController::handleSave($this->jsonRequest([
123 'source' => ['kind' => 'post', 'id' => $postId],
124 'blockId' => 1,
125 'blockType' => 'core/paragraph',
126 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
127 ]));
128
129 $this->assertSame(403, $res->get_status());
130 $this->assertSame('forbidden for this source', $res->get_data()['error']);
131 }
132
133 // The post-path object check is edit_post on the *specific* id,
134 // not the coarse edit_posts. An author holds edit_posts but not
135 // edit_others_posts, so a save against another author's post must 403 —
136 // even with the global gate lowered enough to let the author in. Mutating
137 // userCanEditSource to current_user_can('edit_posts') reopens this.
138 public function test_post_save_forbidden_when_editing_another_authors_post()
139 {
140 $owner = self::factory()->user->create(['role' => 'author']);
141 $postId = self::factory()->post->create([
142 'post_author' => $owner,
143 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
144 ]);
145
146 $attacker = self::factory()->user->create(['role' => 'author']);
147 wp_set_current_user($attacker);
148
149 $res = SaveController::handleSave($this->jsonRequest([
150 'source' => ['kind' => 'post', 'id' => $postId],
151 'blockId' => 1,
152 'blockType' => 'core/paragraph',
153 'patches' => [['fieldKey' => 'content', 'value' => 'tampered']],
154 ]));
155
156 $this->assertSame(403, $res->get_status());
157 $this->assertSame('forbidden for this source', $res->get_data()['error']);
158 $this->assertStringContainsString('<p>hi</p>', get_post($postId)->post_content);
159 }
160
161 // Template parts gate on edit_theme_options, independent of the
162 // post-edit world. An editor can edit others' posts but lacks
163 // edit_theme_options, so a template-part save must 403. Mutating
164 // userCanEditSource's template-part branch to edit_post/edit_posts (which
165 // the editor passes) reopens this.
166 public function test_template_part_save_forbidden_for_user_without_edit_theme_options()
167 {
168 $editor = self::factory()->user->create(['role' => 'editor']);
169 wp_set_current_user($editor);
170 $this->assertTrue(current_user_can('edit_others_posts'));
171 $this->assertFalse(current_user_can('edit_theme_options'));
172
173 $slug = 'qe-test-part-' . wp_generate_password(6, false);
174 $partId = self::factory()->post->create([
175 'post_type' => 'wp_template_part',
176 'post_name' => $slug,
177 'post_status' => 'publish',
178 'post_content' => '<!-- wp:paragraph --><p>part-old</p><!-- /wp:paragraph -->',
179 ]);
180 wp_set_object_terms($partId, get_stylesheet(), 'wp_theme');
181
182 $res = SaveController::handleSave($this->jsonRequest([
183 'source' => ['kind' => 'template-part', 'partSlug' => $slug],
184 'blockId' => 1,
185 'blockType' => 'core/paragraph',
186 'patches' => [['fieldKey' => 'content', 'value' => 'part-new']],
187 ]));
188
189 $this->assertSame(403, $res->get_status());
190 $this->assertSame('forbidden for this source', $res->get_data()['error']);
191 $this->assertStringContainsString('<p>part-old</p>', get_post($partId)->post_content);
192 }
193
194 public function test_blockId_past_end_of_walk_returns_404_with_diagnostics()
195 {
196 $this->loginAsAdmin();
197 $postId = self::factory()->post->create([
198 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
199 ]);
200
201 $res = SaveController::handleSave($this->jsonRequest([
202 'source' => ['kind' => 'post', 'id' => $postId],
203 'blockId' => 99,
204 'blockType' => 'core/paragraph',
205 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
206 ]));
207
208 $this->assertSame(404, $res->get_status());
209 $this->assertSame('block not found in source', $res->get_data()['error']);
210 $this->assertSame(99, $res->get_data()['blockId']);
211 }
212
213 public function test_block_type_mismatch_returns_409_without_writing()
214 {
215 $this->loginAsAdmin();
216 $originalContent = '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->';
217 $postId = self::factory()->post->create(['post_content' => $originalContent]);
218
219 $res = SaveController::handleSave($this->jsonRequest([
220 'source' => ['kind' => 'post', 'id' => $postId],
221 'blockId' => 1,
222 'blockType' => 'core/heading',
223 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
224 ]));
225
226 $this->assertSame(409, $res->get_status());
227 $data = $res->get_data();
228 $this->assertSame('block type mismatch', $data['error']);
229 $this->assertSame('core/heading', $data['expected']);
230 $this->assertSame('core/paragraph', $data['actual']);
231 $this->assertSame($originalContent, get_post($postId)->post_content);
232 }
233
234 public function test_successful_patch_updates_post_and_returns_rendered_html()
235 {
236 $this->loginAsAdmin();
237 $postId = self::factory()->post->create([
238 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
239 ]);
240
241 $res = SaveController::handleSave($this->jsonRequest([
242 'source' => ['kind' => 'post', 'id' => $postId],
243 'blockId' => 1,
244 'blockType' => 'core/paragraph',
245 'patches' => [['fieldKey' => 'content', 'value' => 'updated']],
246 ]));
247
248 $this->assertSame(200, $res->get_status());
249 $data = $res->get_data();
250 $this->assertTrue($data['ok']);
251 $this->assertSame(1, $data['blockId']);
252 $this->assertSame('core/paragraph', $data['blockType']);
253 $this->assertStringContainsString('updated', $data['rendered']);
254
255 $stored = get_post($postId)->post_content;
256 $this->assertStringContainsString('<p>updated</p>', $stored);
257 $this->assertStringContainsString('<!-- wp:paragraph -->', $stored);
258 }
259
260 public function test_patches_applied_in_order()
261 {
262 $this->loginAsAdmin();
263 $postId = self::factory()->post->create([
264 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
265 ]);
266
267 $res = SaveController::handleSave($this->jsonRequest([
268 'source' => ['kind' => 'post', 'id' => $postId],
269 'blockId' => 1,
270 'blockType' => 'core/paragraph',
271 'patches' => [
272 ['fieldKey' => 'content', 'value' => 'first'],
273 ['fieldKey' => 'content', 'value' => 'second'],
274 ],
275 ]));
276
277 $this->assertSame(200, $res->get_status());
278 $this->assertStringContainsString('<p>second</p>', get_post($postId)->post_content);
279 }
280
281 public function test_rawBlock_must_parse_to_exactly_one_block()
282 {
283 $this->loginAsAdmin();
284 $postId = self::factory()->post->create([
285 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
286 ]);
287
288 $two = '<!-- wp:paragraph --><p>a</p><!-- /wp:paragraph -->'
289 . '<!-- wp:paragraph --><p>b</p><!-- /wp:paragraph -->';
290
291 $res = SaveController::handleSave($this->jsonRequest([
292 'source' => ['kind' => 'post', 'id' => $postId],
293 'blockId' => 1,
294 'blockType' => 'core/paragraph',
295 'rawBlock' => $two,
296 ]));
297
298 $this->assertSame(400, $res->get_status());
299 $this->assertSame('rawBlock must parse to exactly one block', $res->get_data()['error']);
300 $this->assertSame(2, $res->get_data()['parsed_count']);
301 }
302
303 public function test_rawBlock_type_must_match_blockType()
304 {
305 $this->loginAsAdmin();
306 $postId = self::factory()->post->create([
307 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
308 ]);
309
310 $res = SaveController::handleSave($this->jsonRequest([
311 'source' => ['kind' => 'post', 'id' => $postId],
312 'blockId' => 1,
313 'blockType' => 'core/paragraph',
314 'rawBlock' => '<!-- wp:heading --><h2>x</h2><!-- /wp:heading -->',
315 ]));
316
317 $this->assertSame(400, $res->get_status());
318 $this->assertSame('rawBlock type does not match blockType', $res->get_data()['error']);
319 $this->assertSame('core/paragraph', $res->get_data()['expected']);
320 $this->assertSame('core/heading', $res->get_data()['got']);
321 }
322
323 public function test_rawBlock_path_bypasses_schema_apply()
324 {
325 $this->loginAsAdmin();
326 $postId = self::factory()->post->create([
327 'post_content' => '<!-- wp:paragraph --><p>old</p><!-- /wp:paragraph -->',
328 ]);
329
330 $res = SaveController::handleSave($this->jsonRequest([
331 'source' => ['kind' => 'post', 'id' => $postId],
332 'blockId' => 1,
333 'blockType' => 'core/paragraph',
334 'rawBlock' => '<!-- wp:paragraph {"align":"right"} --><p class="has-text-align-right">whole-block</p><!-- /wp:paragraph -->',
335 ]));
336
337 $this->assertSame(200, $res->get_status());
338 $stored = get_post($postId)->post_content;
339 $this->assertStringContainsString('<p class="has-text-align-right">whole-block</p>', $stored);
340 $this->assertStringContainsString('{"align":"right"}', $stored);
341 }
342
343 public function test_rawBlock_path_works_even_when_no_schema_registered()
344 {
345 $this->loginAsAdmin();
346 // Wipe registry: rawBlock should still save.
347 $this->resetRegistry();
348
349 $postId = self::factory()->post->create([
350 'post_content' => '<!-- wp:paragraph --><p>old</p><!-- /wp:paragraph -->',
351 ]);
352
353 $res = SaveController::handleSave($this->jsonRequest([
354 'source' => ['kind' => 'post', 'id' => $postId],
355 'blockId' => 1,
356 'blockType' => 'core/paragraph',
357 'rawBlock' => '<!-- wp:paragraph --><p>whole</p><!-- /wp:paragraph -->',
358 ]));
359
360 $this->assertSame(200, $res->get_status());
361 }
362
363 public function test_round_trip_preserves_block_structure_of_unrelated_siblings()
364 {
365 $this->loginAsAdmin();
366 $content = '<!-- wp:paragraph --><p>first</p><!-- /wp:paragraph -->'
367 . '<!-- wp:heading --><h2>middle</h2><!-- /wp:heading -->'
368 . '<!-- wp:paragraph --><p>last</p><!-- /wp:paragraph -->';
369 $postId = self::factory()->post->create(['post_content' => $content]);
370
371 // Edit only the middle heading (blockId=2 in TagBlocks counting).
372 $res = SaveController::handleSave($this->jsonRequest([
373 'source' => ['kind' => 'post', 'id' => $postId],
374 'blockId' => 2,
375 'blockType' => 'core/heading',
376 'patches' => [['fieldKey' => 'content', 'value' => 'new-middle']],
377 ]));
378
379 $this->assertSame(200, $res->get_status());
380 $stored = get_post($postId)->post_content;
381 $this->assertStringContainsString('<p>first</p>', $stored);
382 $this->assertStringContainsString('<h2>new-middle</h2>', $stored);
383 $this->assertStringContainsString('<p>last</p>', $stored);
384 }
385
386 public function test_template_part_resolves_by_slug()
387 {
388 $this->loginAsAdmin();
389 $slug = 'qe-test-part-' . wp_generate_password(6, false);
390 $partId = self::factory()->post->create([
391 'post_type' => 'wp_template_part',
392 'post_name' => $slug,
393 'post_status' => 'publish',
394 'post_content' => '<!-- wp:paragraph --><p>part-old</p><!-- /wp:paragraph -->',
395 ]);
396 wp_set_object_terms($partId, get_stylesheet(), 'wp_theme');
397
398 $res = SaveController::handleSave($this->jsonRequest([
399 'source' => ['kind' => 'template-part', 'partSlug' => $slug],
400 'blockId' => 1,
401 'blockType' => 'core/paragraph',
402 'patches' => [['fieldKey' => 'content', 'value' => 'part-new']],
403 ]));
404
405 $this->assertSame(200, $res->get_status());
406 $this->assertStringContainsString('<p>part-new</p>', get_post($partId)->post_content);
407 }
408
409 // Real-world repro: an install that had `extendable` and `extendable-2`
410 // both active at different points ends up with two wp_template_part
411 // posts sharing post_name='header', one per wp_theme term. WP's render
412 // path scopes to the current theme via get_block_template; raw
413 // get_posts(name=...) doesn't, so a save-side resolver built on
414 // get_posts patches the wrong row and the findBlock parse walk runs
415 // against an unrelated post_content (the user's diagnostic dump:
416 // 11 blocks visited, none of them the social-link the client clicked).
417 //
418 // Bypass wp_unique_post_slug with a direct $wpdb update so both posts
419 // really share the slug — wp_insert_post auto-suffixes `-2` when terms
420 // aren't set at insertion time, which masks this scenario in tests.
421 public function test_template_part_duplicate_slug_resolves_via_active_theme()
422 {
423 global $wpdb;
424 $this->loginAsAdmin();
425 $slug = sanitize_title('qe-test-dup-' . wp_generate_password(6, false));
426
427 $activeThemePartId = self::factory()->post->create([
428 'post_type' => 'wp_template_part',
429 'post_name' => $slug,
430 'post_status' => 'publish',
431 'post_content' => '<!-- wp:paragraph --><p>active-old</p><!-- /wp:paragraph -->',
432 ]);
433 wp_set_object_terms($activeThemePartId, get_stylesheet(), 'wp_theme');
434
435 $orphanThemePartId = self::factory()->post->create([
436 'post_type' => 'wp_template_part',
437 'post_name' => "{$slug}-renamed-by-uniqueness",
438 'post_status' => 'publish',
439 'post_content' => '<!-- wp:paragraph --><p>orphan-untouched</p><!-- /wp:paragraph -->',
440 ]);
441 wp_set_object_terms($orphanThemePartId, 'qe-test-other-theme', 'wp_theme');
442 // Bypass wp_unique_post_slug + force the orphan to win get_posts'
443 // default date-DESC ordering, mirroring the user's diagnostic
444 // (orphan = newer modified-row, active = older modified-row).
445 $wpdb->update(
446 $wpdb->posts,
447 [
448 'post_name' => $slug,
449 'post_date' => '2099-01-01 00:00:00',
450 'post_date_gmt' => '2099-01-01 00:00:00',
451 ],
452 ['ID' => $orphanThemePartId]
453 );
454 clean_post_cache($orphanThemePartId);
455
456 $res = SaveController::handleSave($this->jsonRequest([
457 'source' => ['kind' => 'template-part', 'partSlug' => $slug],
458 'blockId' => 1,
459 'blockType' => 'core/paragraph',
460 'patches' => [['fieldKey' => 'content', 'value' => 'active-new']],
461 ]));
462
463 $this->assertSame(200, $res->get_status());
464 $this->assertStringContainsString('<p>active-new</p>', get_post($activeThemePartId)->post_content);
465 $this->assertStringContainsString('<p>orphan-untouched</p>', get_post($orphanThemePartId)->post_content);
466 }
467
468 public function test_disallowed_post_type_revision_returns_404()
469 {
470 $this->loginAsAdmin();
471 $parentId = self::factory()->post->create(['post_status' => 'publish']);
472 $revisionId = self::factory()->post->create([
473 'post_type' => 'revision',
474 'post_parent' => $parentId,
475 'post_status' => 'inherit',
476 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
477 ]);
478
479 $res = SaveController::handleSave($this->jsonRequest([
480 'source' => ['kind' => 'post', 'id' => $revisionId],
481 'blockId' => 1,
482 'blockType' => 'core/paragraph',
483 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
484 ]));
485
486 $this->assertSame(404, $res->get_status());
487 $this->assertStringContainsString('dedicated endpoint', $res->get_data()['error']);
488 }
489
490 public function test_disallowed_post_type_wp_template_returns_404()
491 {
492 $this->loginAsAdmin();
493 $templateId = self::factory()->post->create([
494 'post_type' => 'wp_template',
495 'post_status' => 'publish',
496 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
497 ]);
498
499 $res = SaveController::handleSave($this->jsonRequest([
500 'source' => ['kind' => 'post', 'id' => $templateId],
501 'blockId' => 1,
502 'blockType' => 'core/paragraph',
503 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
504 ]));
505
506 $this->assertSame(404, $res->get_status());
507 $this->assertStringContainsString('dedicated endpoint', $res->get_data()['error']);
508 }
509
510 public function test_disallowed_post_type_wp_block_returns_404()
511 {
512 $this->loginAsAdmin();
513 $reusableId = self::factory()->post->create([
514 'post_type' => 'wp_block',
515 'post_status' => 'publish',
516 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
517 ]);
518
519 $res = SaveController::handleSave($this->jsonRequest([
520 'source' => ['kind' => 'post', 'id' => $reusableId],
521 'blockId' => 1,
522 'blockType' => 'core/paragraph',
523 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
524 ]));
525
526 $this->assertSame(404, $res->get_status());
527 $this->assertStringContainsString('dedicated endpoint', $res->get_data()['error']);
528 }
529
530 public function test_auto_draft_post_via_post_kind_returns_404()
531 {
532 $this->loginAsAdmin();
533 $autoDraftId = self::factory()->post->create([
534 'post_status' => 'auto-draft',
535 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
536 ]);
537
538 $res = SaveController::handleSave($this->jsonRequest([
539 'source' => ['kind' => 'post', 'id' => $autoDraftId],
540 'blockId' => 1,
541 'blockType' => 'core/paragraph',
542 'patches' => [['fieldKey' => 'content', 'value' => 'x']],
543 ]));
544
545 $this->assertSame(404, $res->get_status());
546 $this->assertStringContainsString('dedicated endpoint', $res->get_data()['error']);
547 }
548
549 public function test_rawBlock_disallowed_block_type_returns_400()
550 {
551 $this->loginAsAdmin();
552 $postId = self::factory()->post->create([
553 'post_content' => '<!-- wp:list --><ul><li>hi</li></ul><!-- /wp:list -->',
554 ]);
555
556 $res = SaveController::handleSave($this->jsonRequest([
557 'source' => ['kind' => 'post', 'id' => $postId],
558 'blockId' => 1,
559 'blockType' => 'core/list',
560 'rawBlock' => '<!-- wp:list --><ul><li>x</li></ul><!-- /wp:list -->',
561 ]));
562
563 $this->assertSame(400, $res->get_status());
564 $this->assertSame('rawBlock not supported for this block type', $res->get_data()['error']);
565 }
566
567 public function test_rawBlock_innerHTML_is_ksesd_in_stored_and_rendered()
568 {
569 $this->loginAsAdmin();
570 $postId = self::factory()->post->create([
571 'post_content' => '<!-- wp:paragraph --><p>old</p><!-- /wp:paragraph -->',
572 ]);
573
574 $malicious = '<!-- wp:paragraph --><p>hello<script>alert(1)</script>'
575 . '<img src="y" onerror="steal()"></p><!-- /wp:paragraph -->';
576
577 $res = SaveController::handleSave($this->jsonRequest([
578 'source' => ['kind' => 'post', 'id' => $postId],
579 'blockId' => 1,
580 'blockType' => 'core/paragraph',
581 'rawBlock' => $malicious,
582 ]));
583
584 $this->assertSame(200, $res->get_status());
585
586 $stored = get_post($postId)->post_content;
587 $rendered = $res->get_data()['rendered'];
588
589 $this->assertStringContainsString('hello', $stored);
590 $this->assertStringNotContainsString('<script', $stored);
591 $this->assertStringNotContainsString('onerror', $stored);
592 $this->assertStringNotContainsString('<script', $rendered);
593 $this->assertStringNotContainsString('onerror', $rendered);
594 }
595
596 // innerHTML is kses'd, but the block-comment `attrs` JSON is client-supplied
597 // and never sanitized by QuickEdit. An attribute-borne payload still can't
598 // reach an executable sink: serialize_block
599 // re-encodes attrs as JSON (angle brackets become < inside the comment),
600 // unknown attrs are dropped at render, and supported attrs (style/fontSize)
601 // run through core's render-time sanitizers. Stored AND rendered must be clean.
602 public function test_rawBlock_malicious_attrs_never_reach_stored_or_rendered()
603 {
604 $this->loginAsAdmin();
605 $postId = self::factory()->post->create([
606 'post_content' => '<!-- wp:paragraph --><p>old</p><!-- /wp:paragraph -->',
607 ]);
608
609 $attrPayloads = [
610 '{"className":"x\"><script>alert(1)</script>"}',
611 '{"style":{"color":{"text":"red;}</style><script>alert(1)</script>"}}}',
612 '{"fontSize":"x\"><script>alert(1)</script>"}',
613 '{"evil":"</p><script>alert(1)</script>"}',
614 ];
615
616 foreach ($attrPayloads as $attrs) {
617 wp_update_post([
618 'ID' => $postId,
619 'post_content' => '<!-- wp:paragraph --><p>old</p><!-- /wp:paragraph -->',
620 ]);
621 $res = SaveController::handleSave($this->jsonRequest([
622 'source' => ['kind' => 'post', 'id' => $postId],
623 'blockId' => 1,
624 'blockType' => 'core/paragraph',
625 'rawBlock' => '<!-- wp:paragraph ' . $attrs . ' --><p>hi</p><!-- /wp:paragraph -->',
626 ]));
627
628 $this->assertSame(200, $res->get_status(), "attrs: {$attrs}");
629 $stored = get_post($postId)->post_content;
630 $rendered = $res->get_data()['rendered'];
631 $this->assertStringNotContainsString('<script', $stored, "stored attrs: {$attrs}");
632 $this->assertStringNotContainsString('<script', $rendered, "rendered attrs: {$attrs}");
633 }
634 }
635
636 // The patches path runs every value through its schema sanitizer; this
637 // exercises it end-to-end through handleSave (not just the schema unit):
638 // a javascript: button url is stripped before it reaches post_content.
639 public function test_patch_url_javascript_scheme_is_stripped_in_stored_content()
640 {
641 $this->loginAsAdmin();
642 $postId = self::factory()->post->create([
643 'post_content' => '<!-- wp:button --><div class="wp-block-button">'
644 . '<a class="wp-block-button__link" href="http://ok">go</a>'
645 . '</div><!-- /wp:button -->',
646 ]);
647
648 $res = SaveController::handleSave($this->jsonRequest([
649 'source' => ['kind' => 'post', 'id' => $postId],
650 'blockId' => 1,
651 'blockType' => 'core/button',
652 'patches' => [['fieldKey' => 'url', 'value' => 'javascript:alert(1)']],
653 ]));
654
655 $this->assertSame(200, $res->get_status());
656 $stored = get_post($postId)->post_content;
657 $this->assertStringNotContainsString('javascript:', $stored);
658 $this->assertStringNotContainsString('href=', $stored);
659 }
660
661 // The rawBlock path kses's innerHTML but never esc_url_raw's the url, and
662 // core/button is the only url-carrying type it accepts. kses strips the
663 // javascript: scheme from the href; the comment-attr url survives verbatim
664 // but a static block never re-emits it, so neither exit is executable.
665 public function test_rawBlock_button_javascript_url_never_reaches_rendered_or_stored_href()
666 {
667 $this->loginAsAdmin();
668 $postId = self::factory()->post->create([
669 'post_content' => '<!-- wp:button --><div class="wp-block-button">'
670 . '<a class="wp-block-button__link" href="http://ok">go</a>'
671 . '</div><!-- /wp:button -->',
672 ]);
673
674 $res = SaveController::handleSave($this->jsonRequest([
675 'source' => ['kind' => 'post', 'id' => $postId],
676 'blockId' => 1,
677 'blockType' => 'core/button',
678 'rawBlock' => '<!-- wp:button {"url":"javascript:alert(1)"} -->'
679 . '<div class="wp-block-button">'
680 . '<a class="wp-block-button__link" href="javascript:alert(1)">x</a>'
681 . '</div><!-- /wp:button -->',
682 ]));
683
684 $this->assertSame(200, $res->get_status());
685 $stored = get_post($postId)->post_content;
686 $rendered = $res->get_data()['rendered'];
687 $this->assertStringNotContainsString('javascript:', $rendered);
688 $this->assertStringNotContainsString('href="javascript:', $stored);
689 }
690
691 public function test_rawBlock_heading_passes_allowlist_and_keeps_safe_markup()
692 {
693 $this->loginAsAdmin();
694 $postId = self::factory()->post->create([
695 'post_content' => '<!-- wp:heading --><h2>old</h2><!-- /wp:heading -->',
696 ]);
697
698 $res = SaveController::handleSave($this->jsonRequest([
699 'source' => ['kind' => 'post', 'id' => $postId],
700 'blockId' => 1,
701 'blockType' => 'core/heading',
702 'rawBlock' => '<!-- wp:heading --><h2>Safe <strong>title</strong></h2><!-- /wp:heading -->',
703 ]));
704
705 $this->assertSame(200, $res->get_status());
706 $stored = get_post($postId)->post_content;
707 $this->assertStringContainsString('<h2>Safe <strong>title</strong></h2>', $stored);
708 }
709
710 // The header phone CTA is a core/paragraph whose only content is a tel:
711 // link (AutoLaunch's ext-nav-extras-phone). Editing the visible digits in
712 // RichText keeps the link format but only swaps the text, so the anchor's
713 // href/data-id stay pointed at the OLD number and tap-to-call would dial
714 // the stale digits. The save must re-point href + data-id (and pin
715 // data-type) at the number the user now sees.
716 public function test_phone_paragraph_tel_link_syncs_to_edited_digits()
717 {
718 $this->loginAsAdmin();
719 $slug = 'qe-test-header-' . wp_generate_password(6, false);
720 $partId = self::factory()->post->create([
721 'post_type' => 'wp_template_part',
722 'post_name' => $slug,
723 'post_status' => 'publish',
724 'post_content' => '<!-- wp:paragraph {"className":"no-underline"} -->'
725 . '<p class="no-underline"><a href="tel:206-555-0100" data-type="tel" data-id="tel:206-555-0100">206-555-0100</a></p>'
726 . '<!-- /wp:paragraph -->',
727 ]);
728 wp_set_object_terms($partId, get_stylesheet(), 'wp_theme');
729
730 // RichText preserves the anchor (href/data-id stale) and changes text only.
731 $res = SaveController::handleSave($this->jsonRequest([
732 'source' => ['kind' => 'template-part', 'partSlug' => $slug],
733 'blockId' => 1,
734 'blockType' => 'core/paragraph',
735 'rawBlock' => '<!-- wp:paragraph {"className":"no-underline"} -->'
736 . '<p class="no-underline"><a href="tel:206-555-0100" data-type="tel" data-id="tel:206-555-0100">206-555-9999</a></p>'
737 . '<!-- /wp:paragraph -->',
738 ]));
739
740 $this->assertSame(200, $res->get_status());
741 $stored = get_post($partId)->post_content;
742 $rendered = $res->get_data()['rendered'];
743
744 // All three anchor attributes track the edited digits and agree.
745 $this->assertStringContainsString('href="tel:2065559999"', $stored);
746 $this->assertStringContainsString('data-id="tel:2065559999"', $stored);
747 $this->assertStringContainsString('data-type="tel"', $stored);
748 // The stale number is gone from the link target...
749 $this->assertStringNotContainsString('tel:206-555-0100', $stored);
750 // ...but the visible digits the user typed are left untouched.
751 $this->assertStringContainsString('>206-555-9999<', $stored);
752 // The re-rendered HTML spliced into the live DOM dials the new number.
753 $this->assertStringContainsString('href="tel:2065559999"', $rendered);
754 }
755
756 // A leading + (international dialing prefix) is the one non-digit kept; the
757 // rest of the visual formatting (spaces, parens, dashes) is stripped.
758 public function test_phone_paragraph_keeps_leading_plus_for_international_numbers()
759 {
760 $this->loginAsAdmin();
761 $postId = self::factory()->post->create([
762 'post_content' => '<!-- wp:paragraph --><p><a href="tel:2065550100" data-type="tel" data-id="tel:2065550100">206-555-0100</a></p><!-- /wp:paragraph -->',
763 ]);
764
765 $res = SaveController::handleSave($this->jsonRequest([
766 'source' => ['kind' => 'post', 'id' => $postId],
767 'blockId' => 1,
768 'blockType' => 'core/paragraph',
769 'rawBlock' => '<!-- wp:paragraph --><p><a href="tel:2065550100" data-type="tel" data-id="tel:2065550100">+1 (206) 555-0199</a></p><!-- /wp:paragraph -->',
770 ]));
771
772 $this->assertSame(200, $res->get_status());
773 $stored = get_post($postId)->post_content;
774 $this->assertStringContainsString('href="tel:+12065550199"', $stored);
775 $this->assertStringContainsString('data-id="tel:+12065550199"', $stored);
776 }
777
778 // The rewrite is scoped to tel: links: a paragraph whose link is an
779 // ordinary http(s) URL is saved exactly as the editor serialized it.
780 public function test_paragraph_with_http_link_is_left_untouched()
781 {
782 $this->loginAsAdmin();
783 $postId = self::factory()->post->create([
784 'post_content' => '<!-- wp:paragraph --><p>old</p><!-- /wp:paragraph -->',
785 ]);
786
787 $res = SaveController::handleSave($this->jsonRequest([
788 'source' => ['kind' => 'post', 'id' => $postId],
789 'blockId' => 1,
790 'blockType' => 'core/paragraph',
791 'rawBlock' => '<!-- wp:paragraph --><p>Visit <a href="https://example.com/contact">our page</a> today</p><!-- /wp:paragraph -->',
792 ]));
793
794 $this->assertSame(200, $res->get_status());
795 $stored = get_post($postId)->post_content;
796 $this->assertStringContainsString('href="https://example.com/contact"', $stored);
797 $this->assertStringNotContainsString('tel:', $stored);
798 }
799
800 // If the edited text yields no usable phone number, leave the existing
801 // tel: href intact rather than writing a broken link.
802 public function test_tel_link_with_unparseable_text_keeps_prior_href()
803 {
804 $this->loginAsAdmin();
805 $postId = self::factory()->post->create([
806 'post_content' => '<!-- wp:paragraph --><p>old</p><!-- /wp:paragraph -->',
807 ]);
808
809 $res = SaveController::handleSave($this->jsonRequest([
810 'source' => ['kind' => 'post', 'id' => $postId],
811 'blockId' => 1,
812 'blockType' => 'core/paragraph',
813 'rawBlock' => '<!-- wp:paragraph --><p><a href="tel:206-555-0100" data-type="tel" data-id="tel:206-555-0100">Call us today!</a></p><!-- /wp:paragraph -->',
814 ]));
815
816 $this->assertSame(200, $res->get_status());
817 $stored = get_post($postId)->post_content;
818 $this->assertStringContainsString('href="tel:206-555-0100"', $stored);
819 $this->assertStringContainsString('Call us today!', $stored);
820 }
821
822 public function test_init_hooks_registerRoutes_into_rest_api_init()
823 {
824 remove_all_filters('rest_api_init');
825 SaveController::init();
826
827 $this->assertNotFalse(
828 has_action('rest_api_init', [SaveController::class, 'registerRoutes'])
829 );
830 }
831
832 // Block ids are best-effort (render-time vs parse-time can diverge). When
833 // the count lands on the wrong same-type block, the fingerprint identifies
834 // the one the user clicked — recover to it rather than refusing or
835 // corrupting the counted block.
836 public function test_fingerprint_recovers_to_matching_block_when_count_misresolves()
837 {
838 $this->loginAsAdmin();
839 $content = '<!-- wp:paragraph --><p>alpha</p><!-- /wp:paragraph -->'
840 . '<!-- wp:paragraph --><p>bravo</p><!-- /wp:paragraph -->';
841 $postId = self::factory()->post->create(['post_content' => $content]);
842
843 // blockId 1 counts to "alpha", but the user clicked "bravo".
844 $res = SaveController::handleSave($this->jsonRequest([
845 'source' => ['kind' => 'post', 'id' => $postId],
846 'blockId' => 1,
847 'blockType' => 'core/paragraph',
848 'fingerprint' => ['text' => 'bravo'],
849 'patches' => [['fieldKey' => 'content', 'value' => 'updated']],
850 ]));
851
852 $this->assertSame(200, $res->get_status());
853 $stored = get_post($postId)->post_content;
854 $this->assertStringContainsString('<p>alpha</p>', $stored);
855 $this->assertStringContainsString('<p>updated</p>', $stored);
856 $this->assertStringNotContainsString('<p>bravo</p>', $stored);
857 }
858
859 // Recovery only fires on a unique match. When the fingerprint matches no
860 // block of this type, refuse without writing (no silent corruption).
861 public function test_fingerprint_with_no_matching_block_returns_409()
862 {
863 $this->loginAsAdmin();
864 $content = '<!-- wp:paragraph --><p>alpha</p><!-- /wp:paragraph -->'
865 . '<!-- wp:paragraph --><p>bravo</p><!-- /wp:paragraph -->';
866 $postId = self::factory()->post->create(['post_content' => $content]);
867
868 $res = SaveController::handleSave($this->jsonRequest([
869 'source' => ['kind' => 'post', 'id' => $postId],
870 'blockId' => 1,
871 'blockType' => 'core/paragraph',
872 'fingerprint' => ['text' => 'no block has this text'],
873 'patches' => [['fieldKey' => 'content', 'value' => 'corrupted']],
874 ]));
875
876 $this->assertSame(409, $res->get_status());
877 $this->assertSame('block fingerprint mismatch', $res->get_data()['error']);
878 $this->assertSame($content, get_post($postId)->post_content);
879 }
880
881 // Ambiguous match (two blocks share the fingerprint text) can't be resolved
882 // safely — refuse rather than guess.
883 public function test_fingerprint_ambiguous_match_returns_409()
884 {
885 $this->loginAsAdmin();
886 $content = '<!-- wp:paragraph --><p>same text</p><!-- /wp:paragraph -->'
887 . '<!-- wp:paragraph --><p>same text</p><!-- /wp:paragraph -->';
888 $postId = self::factory()->post->create(['post_content' => $content]);
889
890 // blockId 5 is past the end, forcing recovery; fingerprint matches both.
891 $res = SaveController::handleSave($this->jsonRequest([
892 'source' => ['kind' => 'post', 'id' => $postId],
893 'blockId' => 5,
894 'blockType' => 'core/paragraph',
895 'fingerprint' => ['text' => 'same text'],
896 'patches' => [['fieldKey' => 'content', 'value' => 'corrupted']],
897 ]));
898
899 $this->assertSame(409, $res->get_status());
900 $this->assertSame($content, get_post($postId)->post_content);
901 }
902
903 // A block-level shortcode render (e.g. [products] -> a <div>) can't nest in
904 // a <p>, so the browser splits the paragraph and the live element's text is
905 // truncated. The fingerprint is then only a prefix of the stored block;
906 // recover to the unique block it prefixes.
907 public function test_fingerprint_recovers_via_prefix_when_render_truncates_the_paragraph()
908 {
909 $this->loginAsAdmin();
910 $content = '<!-- wp:paragraph --><p>oane[products]new line</p><!-- /wp:paragraph -->'
911 . '<!-- wp:paragraph --><p>another text entirely</p><!-- /wp:paragraph -->'
912 . '<!-- wp:paragraph --><p>small</p><!-- /wp:paragraph -->';
913 $postId = self::factory()->post->create(['post_content' => $content]);
914
915 // The live element was truncated to "oane"; the stored block is the full
916 // "oane[products]new line".
917 $res = SaveController::handleSave($this->jsonRequest([
918 'source' => ['kind' => 'post', 'id' => $postId],
919 'blockId' => 1,
920 'blockType' => 'core/paragraph',
921 'fingerprint' => ['text' => 'oane'],
922 'rawBlock' => '<!-- wp:paragraph --><p>oane[products]new line and more</p><!-- /wp:paragraph -->',
923 ]));
924
925 $this->assertSame(200, $res->get_status());
926 $this->assertStringContainsString('oane[products]new line and more', get_post($postId)->post_content);
927 $this->assertStringContainsString('<p>small</p>', get_post($postId)->post_content);
928 }
929
930 // A truncated (prefix) fingerprint that prefixes two blocks can't be
931 // resolved — refuse rather than guess.
932 public function test_prefix_match_ambiguous_returns_409()
933 {
934 $this->loginAsAdmin();
935 $content = '<!-- wp:paragraph --><p>oane first variant</p><!-- /wp:paragraph -->'
936 . '<!-- wp:paragraph --><p>oane second variant</p><!-- /wp:paragraph -->';
937 $postId = self::factory()->post->create(['post_content' => $content]);
938
939 $res = SaveController::handleSave($this->jsonRequest([
940 'source' => ['kind' => 'post', 'id' => $postId],
941 'blockId' => 1,
942 'blockType' => 'core/paragraph',
943 'fingerprint' => ['text' => 'oane'],
944 'patches' => [['fieldKey' => 'content', 'value' => 'corrupted']],
945 ]));
946
947 $this->assertSame(409, $res->get_status());
948 $this->assertSame($content, get_post($postId)->post_content);
949 }
950
951 // The general fingerprint recovery resolves social-links by their unique
952 // `service` attr when the count misses — the same job the old social-link
953 // special-case did, now covered by findBlocksByFingerprint.
954 public function test_social_link_recovers_by_service_when_count_misses()
955 {
956 $this->loginAsAdmin();
957 $content = '<!-- wp:social-links --><ul class="wp-block-social-links">'
958 . '<!-- wp:social-link {"url":"https://fb.com/x","service":"facebook"} /-->'
959 . '<!-- wp:social-link {"url":"https://x.com/y","service":"twitter"} /-->'
960 . '</ul><!-- /wp:social-links -->';
961 $postId = self::factory()->post->create(['post_content' => $content]);
962
963 // blockId past the end forces a count miss; service identifies the item.
964 $res = SaveController::handleSave($this->jsonRequest([
965 'source' => ['kind' => 'post', 'id' => $postId],
966 'blockId' => 99,
967 'blockType' => 'core/social-link',
968 'fingerprint' => ['service' => 'twitter'],
969 'patches' => [['fieldKey' => 'url', 'value' => 'https://x.com/updated']],
970 ]));
971
972 $this->assertSame(200, $res->get_status());
973 $stored = get_post($postId)->post_content;
974 $this->assertStringContainsString('https://x.com/updated', $stored);
975 $this->assertStringContainsString('https://fb.com/x', $stored);
976 }
977
978 public function test_matching_fingerprint_allows_save()
979 {
980 $this->loginAsAdmin();
981 $content = '<!-- wp:paragraph --><p>alpha</p><!-- /wp:paragraph -->'
982 . '<!-- wp:paragraph --><p>bravo</p><!-- /wp:paragraph -->';
983 $postId = self::factory()->post->create(['post_content' => $content]);
984
985 $res = SaveController::handleSave($this->jsonRequest([
986 'source' => ['kind' => 'post', 'id' => $postId],
987 'blockId' => 2,
988 'blockType' => 'core/paragraph',
989 'fingerprint' => ['text' => 'bravo'],
990 'patches' => [['fieldKey' => 'content', 'value' => 'updated']],
991 ]));
992
993 $this->assertSame(200, $res->get_status());
994 $stored = get_post($postId)->post_content;
995 $this->assertStringContainsString('<p>alpha</p>', $stored);
996 $this->assertStringContainsString('<p>updated</p>', $stored);
997 }
998
999 // Recovery applies to the rawBlock (text-editor) path too: the count points
1000 // at the wrong block, the fingerprint identifies the right one, and the
1001 // whole-block rawBlock lands there.
1002 public function test_rawBlock_save_recovers_to_matching_block()
1003 {
1004 $this->loginAsAdmin();
1005 $content = '<!-- wp:paragraph --><p>alpha</p><!-- /wp:paragraph -->'
1006 . '<!-- wp:paragraph --><p>bravo</p><!-- /wp:paragraph -->';
1007 $postId = self::factory()->post->create(['post_content' => $content]);
1008
1009 // blockId 2 counts to "bravo"; the user clicked "alpha".
1010 $res = SaveController::handleSave($this->jsonRequest([
1011 'source' => ['kind' => 'post', 'id' => $postId],
1012 'blockId' => 2,
1013 'blockType' => 'core/paragraph',
1014 'fingerprint' => ['text' => 'alpha'],
1015 'rawBlock' => '<!-- wp:paragraph --><p>alpha rewritten</p><!-- /wp:paragraph -->',
1016 ]));
1017
1018 $this->assertSame(200, $res->get_status());
1019 $stored = get_post($postId)->post_content;
1020 $this->assertStringContainsString('<p>alpha rewritten</p>', $stored);
1021 $this->assertStringContainsString('<p>bravo</p>', $stored);
1022 $this->assertStringNotContainsString('<p>alpha</p>', $stored);
1023 }
1024
1025 // The client reads a block's text from the rendered DOM, where the_content
1026 // has run wptexturize (straight apostrophe -> curly). The fingerprint of
1027 // that rendered text must still match the straight-quote markup the server
1028 // parses, or every prose block with an apostrophe false-409s. (Field
1029 // report: editing a paragraph containing "Woody's".)
1030 public function test_fingerprint_matches_wptexturized_rendered_text()
1031 {
1032 $this->loginAsAdmin();
1033 $content = "<!-- wp:paragraph --><p>Welcome to Woody's shop, the best deals</p><!-- /wp:paragraph -->";
1034 $postId = self::factory()->post->create(['post_content' => $content]);
1035
1036 // Mimic the browser: wptexturize emits &#8217; in the HTML, and the
1037 // client reads the live node's textContent, which decodes it to a
1038 // curly apostrophe.
1039 $rendered = apply_filters('the_content', get_post($postId)->post_content);
1040 $renderedText = trim(html_entity_decode(wp_strip_all_tags($rendered), ENT_QUOTES));
1041 $this->assertStringContainsString(
1042 "\u{2019}",
1043 $renderedText,
1044 'sanity: wptexturize should have curled the apostrophe in the rendered text'
1045 );
1046
1047 $res = SaveController::handleSave($this->jsonRequest([
1048 'source' => ['kind' => 'post', 'id' => $postId],
1049 'blockId' => 1,
1050 'blockType' => 'core/paragraph',
1051 'fingerprint' => ['text' => $renderedText],
1052 'patches' => [['fieldKey' => 'content', 'value' => 'updated']],
1053 ]));
1054
1055 $this->assertSame(200, $res->get_status());
1056 $this->assertStringContainsString('updated', get_post($postId)->post_content);
1057 }
1058
1059 // A shortcode (or any the_content transform) renders to different text than
1060 // the raw stored markup, so the client's rendered-DOM fingerprint can't
1061 // match the parsed markup. The gate must render the block the same way the
1062 // page does and retry before refusing. (Field report: a paragraph with a
1063 // shortcode became permanently uneditable.)
1064 public function test_fingerprint_falls_back_to_rendered_text_for_shortcodes()
1065 {
1066 $this->loginAsAdmin();
1067 add_shortcode('ext_fp_probe', static fn () => 'EXPANDED');
1068 $content = '<!-- wp:paragraph --><p>Hello [ext_fp_probe] world</p><!-- /wp:paragraph -->';
1069 $postId = self::factory()->post->create(['post_content' => $content]);
1070
1071 // Client reads "Hello EXPANDED world" from the DOM; raw markup still has
1072 // the literal "[ext_fp_probe]".
1073 $res = SaveController::handleSave($this->jsonRequest([
1074 'source' => ['kind' => 'post', 'id' => $postId],
1075 'blockId' => 1,
1076 'blockType' => 'core/paragraph',
1077 'fingerprint' => ['text' => 'Hello EXPANDED world'],
1078 'patches' => [['fieldKey' => 'content', 'value' => 'updated']],
1079 ]));
1080 remove_shortcode('ext_fp_probe');
1081
1082 $this->assertSame(200, $res->get_status());
1083 $this->assertStringContainsString('updated', get_post($postId)->post_content);
1084 }
1085
1086 // The re-render echo sets $GLOBALS['post'] to the source, but in a REST
1087 // request wp_reset_postdata() can't restore it (the main query has no
1088 // post), so a template-part save would leave global $post dangling. The
1089 // save must snapshot and restore it.
1090 public function test_global_post_is_restored_after_save()
1091 {
1092 $this->loginAsAdmin();
1093 $sentinelId = self::factory()->post->create([
1094 'post_content' => '<!-- wp:paragraph --><p>sentinel</p><!-- /wp:paragraph -->',
1095 ]);
1096 $targetId = self::factory()->post->create([
1097 'post_content' => '<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->',
1098 ]);
1099
1100 $GLOBALS['post'] = get_post($sentinelId);
1101 $before = $GLOBALS['post'];
1102
1103 $res = SaveController::handleSave($this->jsonRequest([
1104 'source' => ['kind' => 'post', 'id' => $targetId],
1105 'blockId' => 1,
1106 'blockType' => 'core/paragraph',
1107 'patches' => [['fieldKey' => 'content', 'value' => 'updated']],
1108 ]));
1109
1110 $this->assertSame(200, $res->get_status());
1111 $after = $GLOBALS['post'] ?? null;
1112 $this->assertSame($before, $after);
1113 $this->assertInstanceOf(\WP_Post::class, $after);
1114 $this->assertSame($sentinelId, $after->ID);
1115 }
1116
1117 // --- Translated-content guard ---
1118
1119 // The corruption hole this closes: a text save with no fingerprint would
1120 // otherwise pass the count gate and overwrite the source post_content. On a
1121 // translated render (client-forwarded context) the rawBlock save is refused
1122 // even without a fingerprint, and nothing is written.
1123 public function test_rawBlock_text_save_on_translated_render_is_refused_without_fingerprint()
1124 {
1125 $this->loginAsAdmin();
1126 $content = '<!-- wp:paragraph --><p>hola</p><!-- /wp:paragraph -->';
1127 $postId = self::factory()->post->create(['post_content' => $content]);
1128
1129 $res = SaveController::handleSave($this->jsonRequest([
1130 'source' => ['kind' => 'post', 'id' => $postId],
1131 'blockId' => 1,
1132 'blockType' => 'core/paragraph',
1133 'rawBlock' => '<!-- wp:paragraph --><p>overwritten</p><!-- /wp:paragraph -->',
1134 'translatedContext' => ['isTranslated' => true, 'plugin' => 'translatepress'],
1135 ]));
1136
1137 $this->assertSame(409, $res->get_status());
1138 $this->assertSame('translated_content', $res->get_data()['error']);
1139 $this->assertSame($content, get_post($postId)->post_content);
1140 }
1141
1142 // Server-side detection is the backstop when the client doesn't forward
1143 // context: a TranslatePress non-default render (default en_US, current
1144 // es_ES) refuses the text save on its own.
1145 public function test_rawBlock_text_save_refused_when_server_detects_translatepress()
1146 {
1147 $this->loginAsAdmin();
1148 update_option('trp_settings', ['default-language' => 'en_US']);
1149 $GLOBALS['TRP_LANGUAGE'] = 'es_ES';
1150
1151 $content = '<!-- wp:heading --><h2>hola</h2><!-- /wp:heading -->';
1152 $postId = self::factory()->post->create(['post_content' => $content]);
1153
1154 $res = SaveController::handleSave($this->jsonRequest([
1155 'source' => ['kind' => 'post', 'id' => $postId],
1156 'blockId' => 1,
1157 'blockType' => 'core/heading',
1158 'rawBlock' => '<!-- wp:heading --><h2>overwritten</h2><!-- /wp:heading -->',
1159 ]));
1160
1161 $this->assertSame(409, $res->get_status());
1162 $this->assertSame('translated_content', $res->get_data()['error']);
1163 $this->assertSame($content, get_post($postId)->post_content);
1164 }
1165
1166 // The text guard also covers the schema-patch path (content / text fields),
1167 // not just the rawBlock text editor.
1168 public function test_content_patch_on_translated_render_is_refused()
1169 {
1170 $this->loginAsAdmin();
1171 $content = '<!-- wp:paragraph --><p>hola</p><!-- /wp:paragraph -->';
1172 $postId = self::factory()->post->create(['post_content' => $content]);
1173
1174 $res = SaveController::handleSave($this->jsonRequest([
1175 'source' => ['kind' => 'post', 'id' => $postId],
1176 'blockId' => 1,
1177 'blockType' => 'core/paragraph',
1178 'patches' => [['fieldKey' => 'content', 'value' => 'overwritten']],
1179 'translatedContext' => ['isTranslated' => true, 'plugin' => 'polylang'],
1180 ]));
1181
1182 $this->assertSame(409, $res->get_status());
1183 $this->assertSame($content, get_post($postId)->post_content);
1184 }
1185
1186 // Non-text edits stay allowed on a translated render: an alignment patch
1187 // (shared, untranslated layout) saves normally.
1188 public function test_alignment_patch_on_translated_render_is_allowed()
1189 {
1190 $this->loginAsAdmin();
1191 $content = '<!-- wp:paragraph --><p>hola</p><!-- /wp:paragraph -->';
1192 $postId = self::factory()->post->create(['post_content' => $content]);
1193
1194 $res = SaveController::handleSave($this->jsonRequest([
1195 'source' => ['kind' => 'post', 'id' => $postId],
1196 'blockId' => 1,
1197 'blockType' => 'core/paragraph',
1198 'patches' => [['fieldKey' => 'align', 'value' => 'center']],
1199 'translatedContext' => ['isTranslated' => true, 'plugin' => 'translatepress'],
1200 ]));
1201
1202 $this->assertSame(200, $res->get_status());
1203 $this->assertStringContainsString(
1204 'has-text-align-center',
1205 get_post($postId)->post_content
1206 );
1207 }
1208
1209 // Default-language renders are unaffected: a text save with the context
1210 // flagged not-translated proceeds and writes.
1211 public function test_text_save_on_default_language_is_allowed()
1212 {
1213 $this->loginAsAdmin();
1214 $postId = self::factory()->post->create([
1215 'post_content' => '<!-- wp:paragraph --><p>old</p><!-- /wp:paragraph -->',
1216 ]);
1217
1218 $res = SaveController::handleSave($this->jsonRequest([
1219 'source' => ['kind' => 'post', 'id' => $postId],
1220 'blockId' => 1,
1221 'blockType' => 'core/paragraph',
1222 'rawBlock' => '<!-- wp:paragraph --><p>new</p><!-- /wp:paragraph -->',
1223 'translatedContext' => ['isTranslated' => false, 'plugin' => null],
1224 ]));
1225
1226 $this->assertSame(200, $res->get_status());
1227 $this->assertStringContainsString('<p>new</p>', get_post($postId)->post_content);
1228 }
1229
1230 private function jsonRequest(array $body): \WP_REST_Request
1231 {
1232 $req = new \WP_REST_Request('POST', '/extendify/v1/quick-edit/save');
1233 $req->set_header('Content-Type', 'application/json');
1234 $req->set_body(wp_json_encode($body));
1235 return $req;
1236 }
1237
1238 private function loginAsAdmin(): void
1239 {
1240 $admin = self::factory()->user->create(['role' => 'administrator']);
1241 wp_set_current_user($admin);
1242 }
1243
1244 private function resetRegistry(): void
1245 {
1246 $reflection = new ReflectionClass(Registry::class);
1247 $prop = $reflection->getProperty('schemas');
1248 $prop->setAccessible(true);
1249 $prop->setValue(null, []);
1250 }
1251 }
1252