fix: Task 8 review round 1 - quote-aware tokenizer, context check, skipped global nodes (S45)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -394,6 +394,141 @@ final class DiviReplaceTextAbilityTest extends TestCase
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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::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('<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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user