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

164 lines
9.6 KiB
PHP

<?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');
$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";