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