Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php
T
janniclas.loosenandClaude Opus 5.5 d67cd0ede6 feat: lit/divi-replace-text with dry run and per-page tree_rev targets
TextReplacer replaces only in plain-text segments (S8 split), outside
script/style bodies, inserts the replacement cleaned per field kind and
refuses any result whose tags, entities or references differ from the
original plus the inserted text. Unchanged strings are never cleaned.
Per page all or nothing with the tree_rev lock; Throwables are reported
per page (S10).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 16:16:01 +02:00

457 lines
22 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use Throwable;
use WP_Error;
use WP_Post;
final class DiviReplaceTextAbilityTest extends TestCase
{
private const REF = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
private WP_Post $post;
/** @var list<string> */
private array $saved = [];
/** @var list<string> */
private array $queries = [];
/** @var list<Throwable> Thrown by the saver, one per call, before it saves normally. */
private array $failures = [];
/** @param list<int> $ids Rows of the prefilter query. */
private function ability(?string $content = null, bool $unfiltered = false, array $ids = [7]): DiviReplaceTextAbility
{
$this->post = DiviPageLoaderTest::givenPost($content ?? DiviPageLoaderTest::fixture('synthetic-basic.html'));
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]);
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
$schema = DiviPageLoaderTest::schema();
$modules = Mockery::mock(DiviModules::class);
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
$modules->allows('builderVersion')->andReturn('5.2.1');
$design = Mockery::mock(DesignSystemReader::class);
$design->allows('read')->andReturn(DesignSystemTest::sample());
$composition = new DiviComposition($modules, $design, ComposeFixture::sanitizer($unfiltered), ComposeFixture::media());
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content): array {
$failure = array_shift($this->failures);
if ($failure !== null) {
throw $failure;
}
$this->saved[] = $content;
$post->post_content = $content;
return ['revision_id' => 811, 'warnings' => []];
});
$log = Mockery::mock(ChangeLog::class);
$log->allows('record')->andReturn(4711);
$loader = new DiviPageLoader($schema);
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->allows('get_col')->andReturnUsing(function (string $sql) use ($ids): array {
$this->queries[] = $sql;
return $ids;
});
return new DiviReplaceTextAbility(
$schema,
$loader,
new DiviWriter($loader, $saver, $log, $design, $composition),
$db
);
}
/**
* A page with one section, row and column holding $modules.
*
* @param list<array{string, array<string, mixed>}> $modules Block name and attributes.
*/
private static function page(array $modules): string
{
$inner = '';
foreach ($modules as [$name, $attrs]) {
$inner .= '<!-- wp:' . $name . ' ' . Serializer::attributes($attrs) . ' --><!-- /wp:' . $name . ' -->';
}
return '<!-- wp:divi/placeholder --><!-- wp:divi/section {"builderVersion":"5.2.1"} --><!-- wp:divi/row '
. '{"builderVersion":"5.2.1"} --><!-- wp:divi/column {"builderVersion":"5.2.1"} -->' . $inner
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->';
}
/** @return array{string, array<string, mixed>} */
private static function text(string $html): array
{
return ['divi/text', ['content' => ['innerContent' => ['desktop' => ['value' => $html]]], 'builderVersion' => '5.2.1']];
}
/** @return array{string, array<string, mixed>} */
private static function heading(string $title): array
{
return ['divi/heading', ['title' => ['innerContent' => ['desktop' => ['value' => $title]]], 'builderVersion' => '5.2.1']];
}
/** @param array<string, mixed> $input */
private function replaceFor(DiviReplaceTextAbility $ability, array $input): mixed
{
return $ability->execute($input + [
'dry_run' => false,
'confirm' => true,
'targets' => [['post_id' => 7, 'tree_rev' => Rev::of((string) $this->post->post_content)]],
]);
}
/** Desktop value of $path in node $id of the last saved content. */
private function savedValue(string $id, string $path): mixed
{
$block = NodeIndex::build((new Parser())->parse($this->saved[count($this->saved) - 1]))->find($id);
return $block === null ? null : AttrMap::desktop($block->attrs(), $path);
}
/** @return array<string, mixed> */
private static function clientError(WP_Error $error): array
{
return (array) json_decode($error->get_error_message(), true);
}
public function testDryRunListsHitsWithTreeRevAndChangesNothing(): void
{
$ability = $this->ability();
$result = $ability->execute(['search' => 'Termin', 'replace' => 'Beratung']);
self::assertTrue($result['dry_run']);
self::assertSame([], $this->saved);
self::assertSame(7, $result['matches'][0]['post_id']);
self::assertSame(Rev::of($this->post->post_content), $result['matches'][0]['tree_rev']);
self::assertSame(1, $result['matches'][0]['count']);
self::assertSame('s0.r0.c1.m2', $result['matches'][0]['samples'][0]['id']);
self::assertSame('Beratung buchen', $result['matches'][0]['samples'][0]['after']);
self::assertNull($result['next_cursor']);
self::assertStringContainsString("post_type = 'page'", $this->queries[0]);
self::assertStringContainsString("LIKE '%<!-- wp:divi/%'", $this->queries[0]);
self::assertStringContainsString("LIKE '%Termin%'", $this->queries[0]);
self::assertStringNotContainsString('post_author', $this->queries[0]);
self::assertTrue($ability->annotations()['destructive']);
self::assertFalse($ability->isDirect());
}
public function testPrefilterCoversStoredAndJsonEscapedForms(): void
{
$ability = $this->ability();
$ability->execute(['search' => 'Müller & Co', 'replace' => 'X', 'case_sensitive' => false]);
$sql = $this->queries[0];
self::assertStringContainsString("LIKE '%Müller & Co%'", $sql);
self::assertStringContainsString("LIKE '%Müller \\\\u0026 Co%'", $sql, 'serialize_block_attributes form');
self::assertStringContainsString("LIKE '%M\\\\u00fcller \\\\u0026 Co%'", $sql, 'unicode-escaped form');
self::assertStringContainsString("LIKE '%M\\\\u00dcLLER \\\\u0026 CO%'", $sql, 'upper case, case-insensitive');
}
public function testDryRunPagesWithACursorAndOnlyOwnPagesWithoutEditOthers(): void
{
$ability = $this->ability(null, false, [7, 8]);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_pages');
Functions\when('get_current_user_id')->justReturn(5);
$result = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 1]);
self::assertCount(1, $result['matches']);
self::assertIsString($result['next_cursor']);
self::assertStringContainsString('post_author = 5', $this->queries[0]);
self::assertStringContainsString('LIMIT 2 OFFSET 0', $this->queries[0]);
}
public function testDryRunLeavesOutPagesTheUserCannotEdit(): void
{
$ability = $this->ability();
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post');
$result = $ability->execute(['search' => 'Termin', 'replace' => 'X']);
self::assertSame([], $result['matches']);
self::assertSame([], $result['skipped']);
}
public function testRealRunNeedsTargetsAndConfirm(): void
{
$ability = $this->ability();
self::assertSame('lit_validation_failed', $ability->execute(['search' => 'Termin', 'replace' => 'X', 'dry_run' => false])->get_error_code());
self::assertSame('lit_validation_failed', $ability->execute([
'search' => 'Termin', 'replace' => 'X', 'dry_run' => false, 'confirm' => true, 'targets' => [['post_id' => 7]],
])->get_error_code());
self::assertSame('lit_confirm_required', $ability->execute([
'search' => 'Termin', 'replace' => 'X', 'dry_run' => false, 'targets' => [['post_id' => 7, 'tree_rev' => 'abc']],
])->get_error_code());
self::assertSame([], $this->saved);
}
public function testReplacesPerPageAndReportsStaleOnes(): void
{
$ability = $this->ability();
$original = (string) $this->post->post_content;
$treeRev = Rev::of($original);
$result = $ability->execute([
'search' => 'termin',
'replace' => 'Rückruf',
'case_sensitive' => false,
'dry_run' => false,
'confirm' => true,
'targets' => [['post_id' => 7, 'tree_rev' => $treeRev], ['post_id' => 7, 'tree_rev' => $treeRev]],
]);
self::assertFalse($result['dry_run']);
self::assertCount(1, $this->saved);
self::assertStringContainsString('"text":"Rückruf buchen"', $this->saved[0]);
self::assertTrue($result['results'][0]['ok']);
self::assertSame(1, $result['results'][0]['count']);
self::assertSame(811, $result['results'][0]['revision_id']);
self::assertSame(Rev::of($this->saved[0]), $result['results'][0]['tree_rev']);
self::assertFalse($result['results'][1]['ok'], 'second target has a stale tree_rev now');
self::assertStringContainsString('tree_rev passt nicht', $result['results'][1]['error']);
self::assertSame([['id' => '7', 'type' => 'page', 'action' => 'replaced', 'label' => 'Start']], $result['changed']);
self::assertStringContainsString('Eintrag 7:', $result['warnings'][0]);
// Byte identity: only the button's opener changed.
self::assertSame(str_replace('"text":"Termin buchen"', '"text":"Rückruf buchen"', $original), $this->saved[0]);
}
public function testRejectsBadInputBeforeAnyPage(): void
{
$ability = $this->ability();
$short = $ability->execute(['search' => 'T', 'replace' => 'X']);
$reference = $ability->execute(['search' => 'Termin', 'replace' => self::REF, 'dry_run' => false, 'confirm' => true, 'targets' => [['post_id' => 7, 'tree_rev' => 'x']]]);
self::assertSame('lit_validation_failed', $short->get_error_code());
self::assertArrayHasKey('search', self::clientError($short)['fields']);
self::assertSame('lit_validation_failed', $reference->get_error_code());
self::assertArrayHasKey('replace', self::clientError($reference)['fields']);
self::assertSame([], $this->queries);
self::assertSame([], $this->saved);
}
/** Ruling S8 end to end: links, classes, entities and embedded references stay as stored. */
public function testLinksEntitiesAndReferencesSurviveASave(): void
{
$html = '<p class="kontakt"><a href="/kontakt" class="kontakt-link">Kontakt</a> &amp; ' . self::REF . ' kontakt</p>';
$ability = $this->ability(self::page([self::text($html)]));
$result = $this->replaceFor($ability, ['search' => 'kontakt', 'replace' => 'Anfrage', 'case_sensitive' => false]);
self::assertTrue($result['results'][0]['ok'], (string) json_encode($result));
self::assertSame(2, $result['results'][0]['count']);
self::assertSame(
'<p class="kontakt"><a href="/kontakt" class="kontakt-link">Anfrage</a> &amp; ' . self::REF . ' Anfrage</p>',
$this->savedValue('s0.r0.c0.m0', 'content.innerContent')
);
}
/** The inserted text is cleaned by the field kind: stripped in text fields, kses'd in rich text. */
public function testInsertedTextIsCleanedByFieldKind(): void
{
$ability = $this->ability(self::page([
self::heading('Willkommen bei Müller'),
self::text('<p>Müller <iframe src="https://video.test/1"></iframe></p>'),
]));
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => '<em>Schmidt</em><script>alert(1)</script><iframe src=x></iframe>']);
self::assertTrue($result['results'][0]['ok'], (string) json_encode($result));
self::assertSame('Willkommen bei Schmidt', $this->savedValue('s0.r0.c0.m0', 'title.innerContent'));
self::assertSame(
'<p><em>Schmidt</em> <iframe src="https://video.test/1"></iframe></p>',
$this->savedValue('s0.r0.c0.m1', 'content.innerContent'),
'only the inserted text is cleaned; the stored iframe stays'
);
self::assertStringNotContainsString('script', $this->saved[0]);
}
public function testAdministratorsKeepTheirMarkup(): void
{
$ability = $this->ability(self::page([self::text('<p>Müller</p>')]), true);
$this->replaceFor($ability, ['search' => 'Müller', 'replace' => '<iframe src="https://video.test/2"></iframe>']);
self::assertSame('<p><iframe src="https://video.test/2"></iframe></p>', $this->savedValue('s0.r0.c0.m0', 'content.innerContent'));
}
/** Forward risk (Task 1): stored references outside the allow list are never re-cleaned. */
public function testUnchangedValuesAreNeverRecleaned(): void
{
$odd = '$variable({"type":"content","value":{"name":"some_plugin_field","settings":{"enable_html":"on"}}})$';
$heading = self::heading('Müller');
$heading[1]['module'] = ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '$variable({"type":"color","value":{"name":"gcid-unknown","settings":{}}})$']]]]];
$ability = $this->ability(self::page([$heading, self::text('<p>' . $odd . '</p>'), self::text('<p>Müller</p>')]));
$original = (string) $this->post->post_content;
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Schmidt']);
self::assertTrue($result['results'][0]['ok'], (string) json_encode($result));
self::assertSame(2, $result['results'][0]['count']);
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-unknown","settings":{}}})$',
$this->savedValue('s0.r0.c0.m0', 'module.decoration.background')['color']
);
$untouched = '<!-- wp:divi/text ' . Serializer::attributes(self::text('<p>' . $odd . '</p>')[1]) . ' -->';
self::assertStringContainsString($untouched, $original);
self::assertStringContainsString($untouched, $this->saved[0], 'a block without hits keeps every byte');
}
public function testUrlAndCodeFieldsAreNeverTouched(): void
{
$ability = $this->ability(self::page([
['divi/code', ['content' => ['innerContent' => ['desktop' => ['value' => '<div>Müller</div>']]]]],
['divi/button', ['button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Müller', 'linkUrl' => '/Müller']]]]]],
]));
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Schmidt']);
self::assertSame(1, $result['results'][0]['count']);
self::assertSame('<div>Müller</div>', $this->savedValue('s0.r0.c0.m0', 'content.innerContent'));
self::assertSame(['text' => 'Schmidt', 'linkUrl' => '/Müller'], $this->savedValue('s0.r0.c0.m1', 'button.innerContent'));
}
/**
* Ruling S6 (Task 8): every case ends in an error or no change – and nothing is stored.
*
* @return array<string, array{string, string, string, bool}> Stored rich text, search, replacement, unfiltered_html.
*/
public static function malicious(): array
{
$tagSplit = '$variable<x>({"type":"content","value":{"name":"post_title","settings":{}}})$';
return [
'replacement is a reference' => ['<p>Müller</p>', 'Müller', self::REF, false],
'replacement is a reference (admin)' => ['<p>Müller</p>', 'Müller', self::REF, true],
'replacement embeds a reference' => ['<p>Müller</p>', 'Müller', 'Hallo ' . self::REF, true],
'tag-split marker' => ['<p>Müller</p>', 'Müller', $tagSplit, false],
'tag-split marker (admin)' => ['<p>Müller</p>', 'Müller', $tagSplit, true],
'NUL-split marker' => ['<p>Müller</p>', 'Müller', "\$vari\0able(", false],
'VT-split tag' => ['<p>Müller</p>', 'Müller', "\$variable<\x0B>(", false],
'octet-split marker' => ['<p>Müller</p>', 'Müller', '$varia%61ble(', false],
'search for the marker' => ['<p>' . self::REF . '</p>', '$variable(', 'x', true],
'search cuts a reference' => ['<p>' . self::REF . '</p>', '$variable', 'x', true],
'search for the reference end' => ['<p>' . self::REF . '</p>', ')$', 'x', true],
'search inside a reference' => ['<p>' . self::REF . '</p>', 'post_title', 'post_date', true],
'search inside a tag' => ['<p><a href="/kontakt" onclick="x">Hallo</a></p>', 'kontakt', 'javascript:alert(1)', true],
'replacement closes a partial reference' => ['<p>$variable({"type":"content","value":{"name":"post_title","settings":{}}} ENDE</p>', 'ENDE', ')$', true],
'removal closes a partial marker' => ['<p>$varia--ble({"type":"content","value":{"name":"post_title","settings":{}}})$</p>', '--', '', true],
'replacement closes a lone "<"' => ['a < b Müller c', 'Müller', 'x onmouseover=alert(1)>', true],
'replacement opens a tag up to a lone ">"' => ['<p>Müller c></p>', 'Müller', '<img src=x onerror=alert(1) ', false],
'replacement completes an entity' => ['<p>Müller;</p>', 'Müller', '&lt', true],
'nested reference' => ['<p>$variable({"type":"content","value":{"name":"post_title","settings":{"before":"$variable(x)$ Müller"}}})$</p>', 'Müller', 'x', true],
'script body' => ['<script>var n = "Müller";</script>', 'Müller', '";alert(1);//', false],
];
}
/** @dataProvider malicious */
public function testMaliciousReplacementsStoreNothing(string $html, string $search, string $replace, bool $unfiltered): void
{
$ability = $this->ability(self::page([self::text($html)]), $unfiltered);
$dry = $ability->execute(['search' => $search, 'replace' => $replace]);
$result = $this->replaceFor($ability, ['search' => $search, 'replace' => $replace]);
self::assertSame([], $this->saved);
if ($result instanceof WP_Error) {
self::assertSame('lit_validation_failed', $result->get_error_code());
self::assertInstanceOf(WP_Error::class, $dry);
return;
}
$entry = $result['results'][0];
self::assertTrue(! $entry['ok'] || $entry['count'] === 0, (string) json_encode($entry));
self::assertSame([], $dry['matches'], 'the dry run shows no hit either');
}
/** Ruling S10: an unexpected error on one page is logged, reported neutrally, and the batch goes on. */
public function testAnUnexpectedErrorOnOnePageDoesNotStopTheBatch(): void
{
$ability = $this->ability();
$treeRev = Rev::of((string) $this->post->post_content);
$this->failures = [new RuntimeException('SQLSTATE secret detail')];
$log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
$previous = ini_set('error_log', $log);
try {
$result = $ability->execute([
'search' => 'Termin',
'replace' => 'Rückruf',
'dry_run' => false,
'confirm' => true,
'targets' => [['post_id' => 7, 'tree_rev' => $treeRev], ['post_id' => 7, 'tree_rev' => $treeRev]],
]);
} finally {
ini_set('error_log', (string) $previous);
}
self::assertFalse($result['results'][0]['ok']);
self::assertStringNotContainsString('SQLSTATE', $result['results'][0]['error']);
self::assertStringContainsString('Interner Fehler', $result['results'][0]['error']);
self::assertTrue($result['results'][1]['ok']);
self::assertCount(1, $this->saved);
self::assertStringContainsString('SQLSTATE secret detail', (string) file_get_contents($log));
unlink($log);
}
public function testWriteFailedKeepsItsOwnMessage(): void
{
$ability = $this->ability();
$this->failures = [new WriteFailed('Die Datenbank hat das Speichern abgelehnt.', true, 7)];
$log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
$previous = ini_set('error_log', $log);
try {
$result = $this->replaceFor($ability, ['search' => 'Termin', 'replace' => 'Rückruf']);
} finally {
ini_set('error_log', (string) $previous);
}
self::assertFalse($result['results'][0]['ok']);
self::assertStringContainsString('Die Datenbank hat das Speichern abgelehnt.', $result['results'][0]['error']);
self::assertStringContainsString('Eintrag 7:', $result['warnings'][0]);
unlink($log);
}
/** A refused string makes its whole page fail (all or nothing per page); other pages go on. */
public function testARefusedStringFailsOnlyItsPage(): void
{
$ability = $this->ability(self::page([self::heading('Müller'), self::text('a < b Müller')]));
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'x>']);
self::assertSame([], $this->saved);
self::assertFalse($result['results'][0]['ok']);
self::assertStringContainsString('s0.r0.c0.m1', $result['results'][0]['error']);
self::assertStringContainsString('content.innerContent', $result['results'][0]['error']);
}
}