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>
604 lines
30 KiB
PHP
604 lines
30 KiB
PHP
<?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> & ' . 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> & ' . 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', '<', 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']);
|
||
}
|
||
}
|