Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php
T
janniclas.loosenandClaude Opus 5.5 c7ce924e5a fix: library IDs stay hidden without library access (ruling P13)
Outline, node-get, write outlines, the render summary and replace-text's
opaque-node match name no library ID unless the library group is visible
and the user has divi_library; read-only still names them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:00:39 +02:00

604 lines
30 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\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\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
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],
bool $library = false
): DiviReplaceTextAbility {
$this->post = DiviFixtures::givenPost($content ?? DiviFixtures::markup('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 = DiviFixtures::schema();
$loader = $library
? new DiviPageLoader($schema, new Parser(), LibraryFixtures::access())
: 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,
DiviWriterFixture::writer($this->saved, $this->failures, $unfiltered, $loader),
$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);
}
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::assertToolError($ability->execute(['search' => 'Termin', 'replace' => 'X', 'dry_run' => false]), 'validation_failed');
self::assertToolError($ability->execute([
'search' => 'Termin', 'replace' => 'X', 'dry_run' => false, 'confirm' => true, 'targets' => [['post_id' => 7]],
]), 'validation_failed');
self::assertToolError($ability->execute([
'search' => 'Termin', 'replace' => 'X', 'dry_run' => false, 'targets' => [['post_id' => 7, 'tree_rev' => 'abc']],
]), 'confirm_required');
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::assertToolError($short, 'validation_failed');
self::assertArrayHasKey('search', self::clientError($short)['fields']);
self::assertToolError($reference, 'validation_failed');
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::assertToolError($result, 'validation_failed');
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 S45 end to end (the live stored XSS): a ">" inside a quoted attribute, comments,
* unterminated tags and "<" in text – no stored change, error or no-op.
*
* @return array<string, array{string, string, string}>
*/
public static function s45(): array
{
return [
'double quotes' => ['<p><a title="A > B" href="/kontakt">Kontakt</a></p>', ' B', '" onmouseover="alert(1)'],
'single quotes' => ["<p><img alt='A > B' src='/b.jpg'></p>", ' B', "' onerror='alert(1)"],
'href after a quoted ">"' => ['<p><a title="x > y" href="/kontakt">Link</a></p>', 'kontakt', 'javascript:alert(1)'],
'comment' => ['<!-- a > B --><p>x</p>', ' B', '--><img src=x onerror=alert(1)>'],
'unterminated tag' => ['<p>ok B</p><a title="x B', ' B', 'y'],
'unbalanced quote' => ['<a title="x>B B</a>', ' B', 'y'],
'lone "<" in text' => ['a < B', ' B', 'img src=x onerror=alert(1)>'],
'"<" + letter in text' => ['a<B c', ' c', 'y'],
];
}
/** @dataProvider s45 */
public function testS45RowsStoreNothing(string $html, string $search, string $replace): void
{
foreach ([false, true] as $unfiltered) {
$this->saved = [];
$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);
$entry = $result['results'][0];
self::assertTrue(! $entry['ok'] || $entry['count'] === 0, (string) json_encode($entry));
self::assertSame([], array_filter($dry['matches'], static fn (array $match): bool => $match['count'] > 0));
}
}
/** Global modules are never changed – but reported, also when they hold the only hits. */
public function testGlobalModulesAreReportedNotChanged(): void
{
$global = ['divi/text', ['globalModule' => '123', 'content' => ['innerContent' => ['desktop' => ['value' => '<p>Müller</p>']]]]];
$ability = $this->ability(self::page([$global, self::text('<p>Meier</p>')]));
$dry = $ability->execute(['search' => 'müller', 'replace' => 'Schmidt', 'case_sensitive' => false]);
$result = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Schmidt']);
$node = ['post_id' => 7, 'id' => 's0.r0.c0.g0', 'reason' => 'globales Modul – in der Bibliothek bearbeiten'];
self::assertSame([$node], $dry['skipped_nodes']);
self::assertSame(7, $dry['matches'][0]['post_id'], 'the page does not vanish');
self::assertSame(0, $dry['matches'][0]['count']);
self::assertSame(1, $dry['matches'][0]['skipped_nodes']);
self::assertStringContainsString('globale Module', $dry['summary']);
self::assertSame([$node], $result['skipped_nodes']);
self::assertSame(0, $result['results'][0]['count']);
self::assertStringContainsString('Knoten s0.r0.c0.g0', implode("\n", $result['warnings']));
self::assertSame([], $this->saved);
}
/**
* Ruling P13: without library access a search for a library ID finds nothing – the ID a global
* instance (and its children's globalParent) stores is as good as absent; with access it is reported.
*/
public function testWithoutLibraryAccessLibraryIdsAreNoHits(): void
{
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
foreach (['1058', '1059', '"globalModule":"1058"', 'globalParent":"1058'] as $search) {
$ability = $this->ability($content);
$dry = $ability->execute(['search' => $search, 'replace' => 'x']);
$result = $this->replaceFor($ability, ['search' => $search, 'replace' => 'x']);
self::assertSame([], $dry['skipped_nodes'], $search);
self::assertSame([], $dry['matches'], $search);
self::assertSame([], $result['skipped_nodes'], $search);
self::assertStringNotContainsString('1058', (string) json_encode([$dry, $result]), $search);
self::assertStringNotContainsString('1059', (string) json_encode([$dry, $result]), $search);
self::assertSame([], $this->saved);
}
$shown = $this->ability($content, false, [7], true)->execute(['search' => '1058', 'replace' => 'x']);
self::assertSame(['g0'], array_column($shown['skipped_nodes'], 'id'));
}
/** Ruling P13: text of a global instance is still reported without library access (not an ID). */
public function testWithoutLibraryAccessGlobalTextIsStillReported(): void
{
$global = ['divi/text', ['globalModule' => '123', 'content' => ['innerContent' => ['desktop' => ['value' => '<p>Müller</p>']]]]];
$ability = $this->ability(self::page([$global]));
$dry = $ability->execute(['search' => 'Müller', 'replace' => 'Schmidt']);
$byId = $ability->execute(['search' => '123', 'replace' => 'x']);
self::assertSame(['s0.r0.c0.g0'], array_column($dry['skipped_nodes'], 'id'));
self::assertSame([], $byId['skipped_nodes']);
}
/** No effective change (search == replacement, cleaned replacement == match): nothing saved. */
public function testNoEffectiveChangeSavesNothing(): void
{
$ability = $this->ability(self::page([self::heading('Müller'), self::text('<p>Müller</p>')]));
$same = $this->replaceFor($ability, ['search' => 'Müller', 'replace' => 'Müller']);
$caseOnly = $this->replaceFor($ability, ['search' => 'müller', 'replace' => 'Müller', 'case_sensitive' => false]);
$dry = $ability->execute(['search' => 'Müller', 'replace' => 'Müller']);
self::assertSame(0, $same['results'][0]['count']);
self::assertSame(0, $caseOnly['results'][0]['count']);
self::assertArrayNotHasKey('revision_id', $same['results'][0]);
self::assertSame([], $dry['matches']);
self::assertSame([], $this->saved);
self::assertSame([], $same['changed']);
}
public function testDescriptionMentionsTrimming(): void
{
self::assertStringContainsString('Leerzeichen am Anfang und Ende', $this->ability()->description());
}
public function testControlCharactersAndEmptySearch(): void
{
$ability = $this->ability();
foreach (['', "Ter\0min", "Ter\x0Bmin"] as $search) {
$error = $ability->execute(['search' => $search, 'replace' => 'X']);
self::assertToolError($error, 'validation_failed', json_encode($search));
self::assertArrayHasKey('search', self::clientError($error)['fields']);
}
$result = $this->replaceFor($ability, ['search' => 'Termin', 'replace' => "R\0\x0Bück\x7Fruf"]);
self::assertTrue($result['results'][0]['ok'], (string) json_encode($result));
self::assertSame(['text' => 'Rückruf buchen', 'linkUrl' => '/kontakt', 'linkTarget' => 'off'], $this->savedValue('s0.r0.c1.m2', 'button.innerContent'));
self::assertSame([], $this->queries);
}
public function testCursorAndLimit(): void
{
$ability = $this->ability(null, false, [7, 8, 9]);
$first = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 2]);
$second = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 2, 'cursor' => $first['next_cursor']]);
$invalid = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'cursor' => 'kaputt']);
$capped = $ability->execute(['search' => 'Termin', 'replace' => 'X', 'limit' => 5000]);
self::assertCount(2, $first['matches']);
self::assertStringContainsString('LIMIT 3 OFFSET 0', $this->queries[0]);
self::assertStringContainsString('LIMIT 3 OFFSET 2', $this->queries[1]);
self::assertIsString($second['next_cursor'], 'the fake query returns three rows again');
self::assertToolError($invalid, 'validation_failed');
self::assertStringContainsString('LIMIT 101 OFFSET 0', $this->queries[2]);
self::assertCount(3, $capped['matches']);
}
/** Byte identity on a page whose other blocks use dynamic content names outside the allow map. */
public function testByteIdentityNextToUnknownDynamicContent(): void
{
$odd = self::text('<p>' . '$variable({"type":"content","value":{"name":"acf_field_x","settings":{"before":"<b>"}}})$' . '</p>');
$odd[1]['module'] = ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '$variable({"type":"color","value":{"name":"gcid-nope","settings":{}}})$']]]]];
$hit = self::heading('Willkommen bei Müller');
$ability = $this->ability(self::page([$odd, $hit, $odd]));
$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));
$changed = $hit;
$changed[1]['title']['innerContent']['desktop']['value'] = 'Willkommen bei Schmidt';
self::assertSame(
str_replace(Serializer::attributes($hit[1]), Serializer::attributes($changed[1]), $original),
$this->saved[0]
);
}
/** 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 = [WriteFailed::rolledBack('Die Datenbank hat das Speichern abgelehnt.', postId: 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']);
}
}