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
This commit is contained in:
2026-09-26 16:16:01 +02:00
co-authored by Claude Opus 5.5
parent ede0221b50
commit d67cd0ede6
9 changed files with 1527 additions and 1 deletions
@@ -323,6 +323,20 @@ final class AttrWriterTest extends TestCase
self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>'));
}
/** kindOf() names the routing cleanValue() applies (lit/divi-replace-text skips url/code). */
public function testKindOfNamesTheRouting(): void
{
$writer = ComposeFixture::attrWriter();
self::assertSame('html', $writer->kindOf('divi/text', 'content.innerContent'));
self::assertSame('text', $writer->kindOf('divi/heading', 'title.innerContent'));
self::assertSame('code', $writer->kindOf('divi/code', 'content.innerContent'));
self::assertSame('text', $writer->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'text'));
self::assertSame('url', $writer->kindOf('divi/fullwidth-header', 'buttonOne.innerContent', 'linkUrl'));
self::assertSame('text', $writer->kindOf('divi/image', 'image.innerContent', 'alt'));
self::assertSame('url', $writer->kindOf('divi/image', 'image.innerContent', 'src'));
}
/** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */
public function testKeysBelowScalarKindsAndUrlPathSegments(): void
{
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
use PHPUnit\Framework\TestCase;
final class TextReplacerTest extends TestCase
{
private const ATTRS = [
'title' => ['innerContent' => ['desktop' => ['value' => 'Müller GmbH'], 'phone' => ['value' => 'Müller']]],
'content' => ['innerContent' => ['desktop' => ['value' => '<p>Seit 1987: Müller &amp; Söhne. Anruf bei MÜLLER.</p>']]],
'button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Müller anrufen', 'linkUrl' => '/mueller-kontakt']]]],
'module' => ['meta' => ['adminLabel' => ['desktop' => ['value' => 'Müller-Hero']]]],
'dyn' => ['innerContent' => ['desktop' => ['value' => '$variable({"type":"content","value":{"name":"Müller","settings":{}}})$']]],
];
private const REF = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
/** @return callable(string, ?string, string): string */
private static function same(): callable
{
return static fn (string $p, ?string $k, string $v): string => $v;
}
/** @param array<string, mixed> $attrs */
private static function text(string $value): array
{
return ['content' => ['innerContent' => ['desktop' => ['value' => $value]]]];
}
/** @param array<string, mixed> $attrs */
private static function valueOf(array $attrs): mixed
{
return $attrs['content']['innerContent']['desktop']['value'];
}
public function testReplacesVisibleTextOnly(): void
{
$seen = [];
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::ATTRS, static function (string $path, ?string $key, string $value) use (&$seen): string {
$seen[] = $path . '|' . ($key ?? '');
return $value;
});
self::assertSame(4, $result['count']);
self::assertSame('Schmidt GmbH', $result['attrs']['title']['innerContent']['desktop']['value']);
self::assertSame('Schmidt', $result['attrs']['title']['innerContent']['phone']['value']);
self::assertSame('<p>Seit 1987: Schmidt &amp; Söhne. Anruf bei MÜLLER.</p>', $result['attrs']['content']['innerContent']['desktop']['value']);
self::assertSame(['text' => 'Schmidt anrufen', 'linkUrl' => '/mueller-kontakt'], $result['attrs']['button']['innerContent']['desktop']['value']);
self::assertSame('Müller-Hero', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']);
self::assertSame(self::ATTRS['dyn'], $result['attrs']['dyn']);
self::assertContains('button.innerContent|text', $seen);
self::assertSame(['path' => 'button.innerContent.text', 'before' => 'Müller anrufen', 'after' => 'Schmidt anrufen'], $result['samples'][0]);
}
public function testCaseInsensitiveIncludingUmlauts(): void
{
$result = (new TextReplacer('müller', 'Schmidt', false))->apply(self::ATTRS, self::same());
self::assertSame(5, $result['count']);
self::assertStringContainsString('Anruf bei Schmidt.', $result['attrs']['content']['innerContent']['desktop']['value']);
}
public function testNothingFound(): void
{
$result = (new TextReplacer('Meier', 'X'))->apply(self::ATTRS, self::same());
self::assertSame(0, $result['count']);
self::assertSame(self::ATTRS, $result['attrs']);
}
/** Ruling S8: tags with their attributes, entities and references are never touched. */
public function testTagsEntitiesAndReferencesStayUntouched(): void
{
$value = '<p class="kontakt-box"><a href="/kontakt" class="kontakt-link" title="Kontakt">Kontakt</a> &amp; '
. 'kontakt@kunde.test ' . self::REF . ' amp</p>';
$result = (new TextReplacer('kontakt', 'Anfrage', false))->apply(self::text($value), self::same());
self::assertSame(
'<p class="kontakt-box"><a href="/kontakt" class="kontakt-link" title="Kontakt">Anfrage</a> &amp; '
. 'Anfrage@kunde.test ' . self::REF . ' amp</p>',
self::valueOf($result['attrs'])
);
self::assertSame(2, $result['count']);
$entity = (new TextReplacer('amp', 'X'))->apply(self::text($value), self::same());
self::assertStringContainsString('&amp; ', (string) self::valueOf($entity['attrs']));
self::assertStringEndsWith(self::REF . ' X</p>', (string) self::valueOf($entity['attrs']));
self::assertSame(1, $entity['count']);
$inside = (new TextReplacer('post_title', 'post_date'))->apply(self::text('Titel: ' . self::REF), self::same());
self::assertSame(0, $inside['count'], 'text inside an embedded reference is not replaced');
}
/** Script and style bodies are code, not visible text (D10: never styles). */
public function testScriptAndStyleBodiesAreNotText(): void
{
$value = '<style>.mueller{color:red}</style><script>var name = "Müller";</script><p>Müller</p>'
. '<SCRIPT type="text/javascript">alert("Müller")</SCRIPT> Müller';
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text($value), self::same());
self::assertSame(
'<style>.mueller{color:red}</style><script>var name = "Müller";</script><p>Schmidt</p>'
. '<SCRIPT type="text/javascript">alert("Müller")</SCRIPT> Schmidt',
self::valueOf($result['attrs'])
);
self::assertSame(2, $result['count']);
$unclosed = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text('<script>x("Müller")'), self::same());
self::assertSame(0, $unclosed['count'], 'an unclosed script runs to the end of the value');
}
/** The clean callback gets the replacement for each place; its result is what is inserted. */
public function testInsertsTheCleanedReplacementPerPlace(): void
{
$calls = [];
$result = (new TextReplacer('Müller', '<b>Schmidt</b>'))->apply(
self::ATTRS,
static function (string $path, ?string $key, string $value) use (&$calls): string {
$calls[] = $path . '|' . ($key ?? '');
return $path === 'title.innerContent' ? strip_tags($value) : $value;
}
);
self::assertSame('Schmidt GmbH', $result['attrs']['title']['innerContent']['desktop']['value']);
self::assertSame('<p>Seit 1987: <b>Schmidt</b> &amp; Söhne. Anruf bei MÜLLER.</p>', self::valueOf($result['attrs']));
self::assertSame(['button.innerContent|text', 'content.innerContent|', 'title.innerContent|'], $calls, 'once per place');
}
/** A place the callback refuses (null: URL or code field) stays as it is. */
public function testNullFromTheCallbackLeavesThePlace(): void
{
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(
self::ATTRS,
static fn (string $p, ?string $k, string $v): ?string => $p === 'content.innerContent' ? null : $v
);
self::assertSame(3, $result['count']);
self::assertSame(self::ATTRS['content'], $result['attrs']['content']);
}
/** Forward risk (Task 1): unchanged strings are never handed to the callback and stay byte for byte. */
public function testUnchangedStringsAreNeverCleaned(): void
{
$odd = '$variable({"type":"content","value":{"name":"not_in_the_allow_list","settings":{"x":"<b>"}}})$';
$attrs = [
'title' => ['innerContent' => ['desktop' => ['value' => 'Müller']]],
'content' => ['innerContent' => ['desktop' => ['value' => $odd], 'tablet' => ['value' => " a\t<br>b %41 "]]],
];
$seen = [];
$result = (new TextReplacer('Müller', 'Schmidt'))->apply($attrs, static function (string $p, ?string $k, string $v) use (&$seen): string {
$seen[] = $p;
return $v;
});
self::assertSame(['title.innerContent'], $seen);
self::assertSame($attrs['content'], $result['attrs']['content']);
}
/**
* Ruling S8 hardening: a result whose tags, entities or references differ from the original
* ones plus the inserted replacement's own is refused – nothing is changed.
*
* @return array<string, array{string, string, string}>
*/
public static function crossBoundary(): array
{
return [
'replacement closes a lone "<" into a tag' => ['a < b Müller c', 'Müller', 'x onmouseover=alert(1)>'],
'replacement opens a tag closed by a lone ">"' => ['Müller c>', 'Müller', '<img src=x onerror=alert(1) '],
'replacement completes an entity' => ['Müller;', 'Müller', '&amp'],
'removal glues an entity together' => ['&amp--; text', '--', ''],
'replacement closes a partial reference' => ['$variable({"type":"content","value":{"name":"post_title","settings":{}}} END', 'END', ')$'],
'nested reference' => ['$variable({"type":"content","value":{"name":"post_title","settings":{"before":"$variable(x)$ Müller"}}})$', 'Müller', 'x'],
'reference across a line break' => ["\$variable({\"type\":\"content\",\n\"value\":{\"name\":\"post_title\"}})$ Müller", 'Müller', 'x'],
'removal glues a marker' => ['$varia--ble( Müller', '--', ''],
];
}
/** @dataProvider crossBoundary */
public function testRefusesResultsThatChangeStructure(string $value, string $search, string $replace): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('content.innerContent');
(new TextReplacer($search, $replace))->apply(self::text($value), self::same());
}
public function testRefusesAMarkerFromTheCallback(): void
{
$this->expectException(InvalidArgumentException::class);
(new TextReplacer('Müller', 'x'))->apply(self::text('Müller'), static fn (): string => self::REF);
}
/** @return array<string, array{string, string, string}> */
public static function badInput(): array
{
return [
'short search' => ['M', 'x', 'search'],
'empty search' => ['', 'x', 'search'],
'search with marker' => ['$variable(', 'x', 'search'],
'invalid UTF-8 search' => ["M\xC3", 'x', 'search'],
'invalid UTF-8 replacement' => ['Müller', "x\xC3", 'replace'],
'replacement is a reference' => ['Müller', self::REF, 'replace'],
'replacement embeds a reference' => ['Müller', 'Hallo ' . self::REF . ' Welt', 'replace'],
'tag-split marker (S28)' => ['Müller', '$variable<x>({"type":"content","value":{"name":"post_title"}})$', 'replace'],
'nested tag-split marker' => ['Müller', '$vari<<x>x>able(', 'replace'],
'NUL-split marker (S32a)' => ['Müller', "\$vari\0able(", 'replace'],
'VT-split marker' => ['Müller', "\$variable\x0B(", 'replace'],
'octet-split marker' => ['Müller', '$v%41ariable(', 'replace'],
'nested octet-split marker' => ['Müller', '$v%4%411ariable(', 'replace'],
];
}
/** @dataProvider badInput */
public function testProblemsNameTheField(string $search, string $replace, string $field): void
{
self::assertArrayHasKey($field, TextReplacer::problems($search, $replace));
$this->expectException(InvalidArgumentException::class);
new TextReplacer($search, $replace);
}
public function testFineInputHasNoProblems(): void
{
self::assertSame([], TextReplacer::problems('Müller', ''));
self::assertSame([], TextReplacer::problems('$5', 'Preis (netto) $'));
}
}
@@ -24,6 +24,7 @@ final class DiviIntegrationTest extends TestCase
'lit/divi-move',
'lit/divi-delete',
'lit/divi-duplicate-node',
'lit/divi-replace-text',
];
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void
@@ -0,0 +1,456 @@
<?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']);
}
}