*/ private array $saved = []; /** @var list */ private array $queries = []; /** @var list Thrown by the saver, one per call, before it saves normally. */ private array $failures = []; /** @param list $ids Rows of the prefilter query. */ private function ability(?string $content = null, bool $unfiltered = false, array $ids = [7]): 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 = 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}> $modules Block name and attributes. */ private static function page(array $modules): string { $inner = ''; foreach ($modules as [$name, $attrs]) { $inner .= ''; } return '' . $inner . ''; } /** @return array{string, array} */ private static function text(string $html): array { return ['divi/text', ['content' => ['innerContent' => ['desktop' => ['value' => $html]]], 'builderVersion' => '5.2.1']]; } /** @return array{string, array} */ private static function heading(string $title): array { return ['divi/heading', ['title' => ['innerContent' => ['desktop' => ['value' => $title]]], 'builderVersion' => '5.2.1']]; } /** @param array $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 */ 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 '%'; 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' => '
Müller
']]]]], ['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('
Müller
', $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 Stored rich text, search, replacement, unfiltered_html. */ public static function malicious(): array { $tagSplit = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$'; return [ 'replacement is a reference' => ['

Müller

', 'Müller', self::REF, false], 'replacement is a reference (admin)' => ['

Müller

', 'Müller', self::REF, true], 'replacement embeds a reference' => ['

Müller

', 'Müller', 'Hallo ' . self::REF, true], 'tag-split marker' => ['

Müller

', 'Müller', $tagSplit, false], 'tag-split marker (admin)' => ['

Müller

', 'Müller', $tagSplit, true], 'NUL-split marker' => ['

Müller

', 'Müller', "\$vari\0able(", false], 'VT-split tag' => ['

Müller

', 'Müller', "\$variable<\x0B>(", false], 'octet-split marker' => ['

Müller

', 'Müller', '$varia%61ble(', false], 'search for the marker' => ['

' . self::REF . '

', '$variable(', 'x', true], 'search cuts a reference' => ['

' . self::REF . '

', '$variable', 'x', true], 'search for the reference end' => ['

' . self::REF . '

', ')$', 'x', true], 'search inside a reference' => ['

' . self::REF . '

', 'post_title', 'post_date', true], 'search inside a tag' => ['

Hallo

', 'kontakt', 'javascript:alert(1)', true], 'replacement closes a partial reference' => ['

$variable({"type":"content","value":{"name":"post_title","settings":{}}} ENDE

', 'ENDE', ')$', true], 'removal closes a partial marker' => ['

$varia--ble({"type":"content","value":{"name":"post_title","settings":{}}})$

', '--', '', 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 ">"' => ['

Müller c>

', 'Müller', ' ['

Müller;

', 'Müller', '<', true], 'nested reference' => ['

$variable({"type":"content","value":{"name":"post_title","settings":{"before":"$variable(x)$ Müller"}}})$

', 'Müller', 'x', true], 'script body' => ['', '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 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 */ public static function s45(): array { return [ 'double quotes' => ['

Kontakt

', ' B', '" onmouseover="alert(1)'], 'single quotes' => ["

A > B

", ' B', "' onerror='alert(1)"], 'href after a quoted ">"' => ['

Link

', 'kontakt', 'javascript:alert(1)'], 'comment' => ['

x

', ' B', '-->'], 'unterminated tag' => ['

ok B

B B', ' B', 'y'], 'lone "<" in text' => ['a < B', ' B', 'img src=x onerror=alert(1)>'], '"<" + letter in text' => ['asaved = []; $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' => '

Müller

']]]]]; $ability = $this->ability(self::page([$global, self::text('

Meier

')])); $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); } /** 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('

Müller

')])); $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::assertSame('lit_validation_failed', $error->get_error_code(), 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::assertSame('lit_validation_failed', $invalid->get_error_code()); 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('

' . '$variable({"type":"content","value":{"name":"acf_field_x","settings":{"before":""}}})$' . '

'); $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']); } }