Files
wp-mojibake-repair/tests/repair.php
T

185 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// Run with: php tests/repair.php (PHP with PDO SQLite and iconv).
define('ABSPATH', __DIR__);
define('ARRAY_A', 'ARRAY_A');
define('DB_NAME', 'repair_test');
function add_action(...$args) {}
function get_current_user_id() { return 7; }
function wp_json_encode($value) { return json_encode($value); }
function wp_salt($scheme) { return 'local-test-salt'; }
function wp_cache_flush() { $GLOBALS['flushes']++; return true; }
require dirname(__DIR__) . '/wp-mojibake-repair.php';
function call_repair($method, ...$args) {
static $methods = array();
if (!isset($methods[$method])) {
$methods[$method] = new ReflectionMethod('WP_Mojibake_Repair_Temporary', $method);
if (PHP_VERSION_ID < 80100) {
$methods[$method]->setAccessible(true);
}
}
return $methods[$method]->invokeArgs(null, $args);
}
$checks = 0;
function same($expected, $actual, $label) {
$GLOBALS['checks']++;
if ($expected !== $actual) {
throw new RuntimeException($label . '\nExpected: ' . var_export($expected, true) . '\nActual: ' . var_export($actual, true));
}
}
function refused($callback, $label) {
try { $callback(); } catch (RuntimeException $e) { $GLOBALS['checks']++; return; }
throw new RuntimeException($label . ': expected refusal');
}
function fix($value) { return call_repair('repair_value', $value); }
function broken($value) { return iconv('ISO-8859-1', 'UTF-8', $value); }
$czech = 'Příliš žluťoučký kůň úpěl ďábelské ódy. ÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ';
same($czech, fix(broken($czech)), 'Latin-1 mojibake, all Czech letters');
// These letters exercise printable Windows-1252 mappings including ™, ¾ and œ.
$windows = 'ěňřšťůžČĎĚŇŘŠŤŮŽ';
same($windows, fix(iconv('Windows-1252', 'UTF-8', $windows)), 'Windows-1252 mojibake');
$punctuation = array(
'–' => '–', '—' => '—', '‘' => '‘', '’' => '’', '‚' => '‚',
'“' => '“', "â€\u{009D}" => '”', '„' => '„', '…' => '…', '•' => '•',
'«' => '«', '»' => '»', '‹' => '‹', '›' => '›', '€' => '€',
'â„¢' => '™', '©' => '©', '®' => '®', '°' => '°', "Â\u{00A0}" => "\u{00A0}",
);
foreach ($punctuation as $damaged => $clean) {
same($clean, fix($damaged), 'Windows-1252 punctuation: ' . $clean);
same($clean, fix(broken($clean)), 'Latin-1 punctuation: ' . $clean);
}
$clean_punctuation = implode('', $punctuation);
same($clean_punctuation, fix($clean_punctuation), 'Correct punctuation and nonbreaking space unchanged');
$damaged_punctuation = implode('', array_keys($punctuation));
same($clean_punctuation, fix(fix($damaged_punctuation)), 'Repeated punctuation repair is stable');
$punctuation_settings = array('font_family' => 'Open Sans', 'label' => $damaged_punctuation);
$punctuation_expected = array('font_family' => 'Open Sans', 'label' => $clean_punctuation);
same(serialize($punctuation_expected), fix(serialize($punctuation_settings)), 'Punctuation repair preserves serialized typography settings');
$punctuation_css = 'body{font-family:"Open Sans"}/* – ’ … */';
same('body{font-family:"Open Sans"}/* – ’ … */', fix($punctuation_css), 'Punctuation in CSS preserves styling');
$punctuation_json = '{"font":"Open Sans","url":"https:\/\/example.test","label":"– ’ …","escaped":"\\u2013"}';
same(strtr($punctuation_json, $punctuation), fix($punctuation_json), 'Punctuation in JSON preserves escaping');
$correct = $czech . ' 日本語 😀 € “quoted” — ß ö ñ';
same($correct, fix($correct), 'Correct multilingual Unicode is byte-for-byte unchanged');
same($correct . ' Příliš', fix($correct . ' ' . broken('Příliš')), 'Mixed clean and damaged text');
same($czech, fix(fix(broken($czech))), 'Second pass preserves repaired text');
$css = 'body{font-family:"Open Sans",sans-serif;font-weight:600;background:url("/a\\b.woff2")}/* ';
same($css . 'Příliš */', fix($css . broken('Příliš') . ' */'), 'CSS bytes stay intact');
$json = '{"font_family":"Open Sans","url":"https:\/\/example.test\/font.woff2","unicode":"\\u010d","text":"' . broken('Příliš') . '"}';
same(str_replace(broken('Příliš'), 'Příliš', $json), fix($json), 'JSON escaping stays intact');
$settings = array('font_family' => 'Open Sans', 'font_weight' => 600, 'label' => broken('Příliš'), 'nested' => array('css' => $css . '*/', 'json' => $json), 'enabled' => true, 'empty' => null, 'scale' => 1.25);
$expected = $settings;
$expected['label'] = 'Příliš';
$expected['nested']['json'] = fix($json);
same(false, @unserialize(call_repair('repair_text', serialize($settings))), 'Replacing serialized text without fixing lengths breaks settings');
$repaired = fix(serialize($settings));
same($expected, unserialize($repaired), 'Serialized font settings remain readable with correct values');
same(serialize($expected), $repaired, 'Serialized byte lengths are recalculated');
same($repaired, fix($repaired), 'Serialized settings survive repeat repair');
$nested = serialize(array('inner' => serialize(array('label' => broken('kůň')))));
same(array('label' => 'kůň'), unserialize(unserialize(fix($nested))['inner']), 'Nested serialization lengths');
same(serialize(array(broken('č') => 'č')), fix(serialize(array(broken('č') => broken('č')))), 'Serialized keys stay unchanged');
$safe = 'a:2:{s:1:"x";d:1.2500;s:1:"y";' . serialize(broken('č')) . '}';
same(str_replace(serialize(broken('č')), serialize('č'), $safe), fix($safe), 'Scalar formatting stays intact');
$malformed = 'a:1:{s:5:"label";s:999:"' . broken('Příliš') . '";}';
refused(function () use ($malformed) { fix($malformed); }, 'Already broken serialization');
refused(function () { fix(serialize(broken('č')) . 'trailing'); }, 'Trailing serialized data');
refused(function () { fix(' ' . serialize(broken('č'))); }, 'Noncanonical serialized wrapper');
refused(function () { fix(broken('č') . "\xff"); }, 'Invalid UTF-8 candidate');
class RepairTestObject {
public $label;
public function __wakeup() { throw new LogicException('Must never instantiate objects'); }
}
$object = new RepairTestObject();
$object->label = broken('č');
refused(function () use ($object) { fix(serialize($object)); }, 'Serialized objects skipped without hooks');
refused(function () use ($object) { fix(serialize(array('label' => broken('č'), 'object' => $object))); }, 'Whole row skipped for nested objects');
$reference = broken('č');
$references = array(&$reference, &$reference);
refused(function () use ($references) { fix(serialize($references)); }, 'References skipped');
$deep = broken('č');
for ($i = 0; $i < 70; $i++) { $deep = array($deep); }
refused(function () use ($deep) { fix(serialize($deep)); }, 'Depth bounded');
// Exercise the scan, exact-value approvals and transaction flow with SQLite.
// This adapter is not a substitute for verification on a WordPress/MySQL site.
class RepairTestDB {
public $prefix = 'wp_';
public $last_error = '';
public $db;
public $fail_update = false;
public $conflict = false;
public function __construct() {
$this->db = new PDO('sqlite::memory:');
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->db->exec('CREATE TABLE wp_options (option_id INTEGER PRIMARY KEY, option_value TEXT)');
}
public function prepare($sql, ...$args) {
return preg_replace_callback('/%[sd]/', function ($match) use (&$args) {
$value = array_shift($args);
return $match[0] === '%d' ? (string) (int) $value : $this->db->quote($value);
}, $sql);
}
public function get_results($sql, $format) { return $this->db->query($sql)->fetchAll(PDO::FETCH_ASSOC); }
public function query($sql) {
if ($sql === 'START TRANSACTION') { return $this->db->beginTransaction() ? 0 : false; }
if ($sql === 'COMMIT') { return $this->db->commit() ? 0 : false; }
if ($sql === 'ROLLBACK') { return $this->db->rollBack() ? 0 : false; }
if ($this->fail_update) { $this->last_error = 'simulated write failure'; return false; }
if ($this->conflict) { return 0; }
// SQLite text equality already compares bytes with its default collation.
return $this->db->exec(str_replace('BINARY ', '', $sql));
}
public function put($id, $text) {
$this->db->prepare('INSERT OR REPLACE INTO wp_options VALUES (?, ?)')->execute(array($id, $text));
}
public function all() { return $this->db->query('SELECT * FROM wp_options ORDER BY option_id')->fetchAll(PDO::FETCH_ASSOC); }
}
$wpdb = new RepairTestDB();
$GLOBALS['flushes'] = 0;
$plan = array('wp_options' => array('key' => 'option_id', 'columns' => array('option_value')));
for ($i = 0; $i < 405; $i++) { $wpdb->put($i, serialize($settings)); }
$wpdb->put(500, $malformed);
$wpdb->put(501, $correct);
$wpdb->put(502, null);
$before = $wpdb->all();
$preview = call_repair('preview', $plan);
same($before, $wpdb->all(), 'Preview is read-only');
same(405, $preview['total'], 'Batches include all rows including ID zero');
same(1, $preview['skipped'], 'Malformed row reported separately');
same(array('500'), $preview['columns'][0]['skipped_ids'], 'Skipped ID shown');
same(5, count($preview['columns'][0]['samples']), 'Sample limit');
$token = call_repair('make_approval', $preview);
$approval = call_repair('verify_approval', $token, $plan);
refused(function () use ($token, $plan) { call_repair('verify_approval', 'x' . $token, $plan); }, 'Tampered approval');
$old = array('user' => 7, 'database' => DB_NAME, 'prefix' => 'wp_', 'expires' => time() + 600, 'counts' => $preview['counts']);
$encoded = rtrim(strtr(base64_encode(json_encode($old)), '+/', '-_'), '=');
$old_token = $encoded . '.' . hash_hmac('sha256', $encoded, wp_salt('auth'));
refused(function () use ($old_token, $plan) { call_repair('verify_approval', $old_token, $plan); }, 'Old blanket-conversion approval rejected');
$wpdb->put(0, broken('Jiný text'));
$changed = $wpdb->all();
refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Same count but changed values rejected');
same($changed, $wpdb->all(), 'Every write rolled back for stale preview');
same(0, $GLOBALS['flushes'], 'No cache flush after rollback');
$wpdb->put(0, serialize($settings));
$wpdb->conflict = true;
refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Concurrent write conflict');
same($before, $wpdb->all(), 'Conflict leaves database unchanged');
$wpdb->conflict = false;
$wpdb->fail_update = true;
refused(function () use ($plan, $approval) { call_repair('execute', $plan, $approval); }, 'Database error rolls back');
same($before, $wpdb->all(), 'Failed update leaves database unchanged');
$wpdb->fail_update = false;
$wpdb->last_error = '';
$result = call_repair('execute', $plan, $approval);
same(405, $result['total'], 'Approved updates committed');
same(1, $GLOBALS['flushes'], 'Object cache cleared after commit');
$after = $wpdb->all();
same($expected, unserialize($after[0]['option_value']), 'Database font settings remain readable');
same($malformed, $after[405]['option_value'], 'Malformed row untouched');
same($correct, $after[406]['option_value'], 'Clean row untouched');
same(null, $after[407]['option_value'], 'NULL untouched');
same(0, call_repair('preview', $plan)['total'], 'Second preview has no repair candidates');
echo 'Passed ' . $checks . " checks.\n";