ab
'); + + self::assertSame([[0, 3], [5, 9]], $spans); + self::assertTrue(HtmlContext::isBoundary($spans, 3)); + self::assertTrue(HtmlContext::isBoundary($spans, 5)); + self::assertFalse(HtmlContext::isBoundary($spans, 1)); + } + + public function testLongInputsStayLinear(): void + { + $start = microtime(true); + foreach (['<', '" inside a quoted attribute value does not end the tag, so nothing after it + * in the tag is text. With the old "<[^>]*>" split these tests fail (the rest of the tag + * was searched), which is how a reverted tokenizer shows. + */ + public function testQuotedGreaterThanDoesNotEndTheTag(): void + { + $double = 'Kontakt und Kontakt'; + $single = "
Kontakt";
+
+ $result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($double), self::same());
+ $image = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($single), self::same());
+
+ self::assertSame('Anfrage und Anfrage', self::valueOf($result['attrs']));
+ self::assertSame(2, $result['count']);
+ self::assertSame("
Anfrage", self::valueOf($image['attrs']));
+ }
+
+ public function testCommentsAreNoText(): void
+ {
+ $value = "Kontakt
Kontakt"; + + $result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same()); + + self::assertSame("Anfrage
Anfrage", self::valueOf($result['attrs'])); + } + + /** More raw-text elements; a closing tag must really close ("" does not). */ + public function testRawTextBodiesOfAllKinds(): void + { + $value = '' + . ''; + + $result = (new TextReplacer('Kontakt', 'Anfrage'))->apply(self::text($value), self::same()); + + self::assertSame(1, $result['count']); + self::assertStringEndsWith('', (string) self::valueOf($result['attrs'])); + } + + /** Search == replacement (or a cleaned replacement equal to the match) is no change and no hit. */ + public function testNoEffectiveChangeIsNoHit(): void + { + $same = (new TextReplacer('Müller', 'Müller'))->apply(self::ATTRS, self::same()); + $cleanedAway = (new TextReplacer('Müller', 'Müller'))->apply( + self::ATTRS, + static fn (string $p, ?string $k, string $v): string => strip_tags($v) + ); + $partly = (new TextReplacer('müller', 'Müller', false))->apply(self::ATTRS, self::same()); + + self::assertSame(0, $same['count']); + self::assertSame(self::ATTRS, $same['attrs']); + self::assertSame(0, $cleanedAway['count']); + self::assertSame(self::ATTRS, $cleanedAway['attrs']); + self::assertSame(1, $partly['count'], 'only MÜLLER changes'); + } + + /** + * Ruling S45 malicious rows: either refused (exception) or no change at all. + * + * @return array
", ' B', "' onerror='alert(1)"],
+ 'href rewrite after quoted ">"' => ['Link', 'kontakt', 'javascript:alert(1)'],
+ 'comment with ">"' => ['x
', ' B', '-->x
', ' B', 'x'], + 'unterminated comment' => ['x B
', ' B', 'y'], + 'unterminated reference' => ['$variable({"type":"content"} B', ' B', ')$'], + ]; + } + + /** @dataProvider s45 */ + public function testS45RowsAreRefusedOrUnchanged(string $value, string $search, string $replace): void + { + try { + $result = (new TextReplacer($search, $replace))->apply(self::text($value), self::same()); + } catch (InvalidArgumentException $e) { + self::assertStringContainsString('content.innerContent', $e->getMessage()); + + return; + } + + self::assertSame(0, $result['count']); + self::assertSame($value, self::valueOf($result['attrs'])); + } + + /** @return array
x
', ' B', '-->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 {