Files
wordpress-theme-aksrt/tests/widget-system/user-profile-phone-harness.php
T
kate522andClaude Fable 5 f9b7997ec2 fix(user): match profile validation hook argument type
Accept the stdClass user data passed by WordPress and derive the user ID only from the hook object. Cover callback registration, nonce and permission guards, and mismatched posted IDs; refresh the identity review dialog contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 20:38:00 +08:00

330 lines
20 KiB
PHP

<?php
/** Phone binding and native profile persistence contract harness. */
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
define('MINUTE_IN_SECONDS', 60);
define('HOUR_IN_SECONDS', 3600);
$GLOBALS['aksrt_profile_meta'] = [];
$GLOBALS['aksrt_profile_users'] = [];
$GLOBALS['aksrt_nonce_valid'] = true;
$GLOBALS['aksrt_can_edit_user'] = true;
$GLOBALS['aksrt_current_user_id'] = 7;
$GLOBALS['aksrt_transients'] = [];
$GLOBALS['aksrt_expected_sms_code'] = '246810';
final class WP_User {
public function __construct(public int $ID) {}
}
final class WP_Error {
private array $errors = [];
public function add(string $code, string $message): void { $this->errors[$code][] = $message; }
public function get_error_codes(): array { return array_keys($this->errors); }
}
final class WP_REST_Request {
public function __construct(private array $params = [], private string $nonce = 'ok') {}
public function get_json_params(): array { return $this->params; }
public function get_header(string $name): string { return $name === 'X-WP-Nonce' ? $this->nonce : ''; }
}
final class WP_REST_Response {
public function __construct(public array $data, public int $status = 200) {}
}
function is_wp_error($value): bool { return $value instanceof WP_Error; }
function is_admin(): bool { return true; }
function current_user_can(string $capability, ...$args): bool {
$GLOBALS['aksrt_capability_checks'][] = [$capability, ...$args];
return $GLOBALS['aksrt_can_edit_user'];
}
function is_user_logged_in(): bool { return true; }
function get_current_user_id(): int { return (int) ($GLOBALS['aksrt_current_user_id'] ?? 7); }
function get_user_meta(int $user_id, string $key, bool $single = false) {
$value = $GLOBALS['aksrt_profile_meta'][$user_id][$key] ?? '';
return $single ? $value : ($value === '' ? [] : [$value]);
}
function update_user_meta(int $user_id, string $key, $value): bool {
$GLOBALS['aksrt_profile_meta'][$user_id][$key] = $value;
return true;
}
function get_users(array $args = []): array {
$results = [];
foreach ($GLOBALS['aksrt_profile_meta'] as $user_id => $meta) {
if (in_array((int) $user_id, array_map('intval', $args['exclude'] ?? []), true)) continue;
if (($meta[$args['meta_key'] ?? ''] ?? null) !== ($args['meta_value'] ?? null)) continue;
$results[] = in_array(($args['fields'] ?? ''), ['ids', 'ID'], true) ? (int) $user_id : new WP_User((int) $user_id);
if (count($results) >= (int) ($args['number'] ?? 1)) break;
}
return $results;
}
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_textarea_field($value): string { return sanitize_text_field($value); }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function wp_unslash($value) { return $value; }
function esc_url_raw($value): string {
$value = trim((string) $value);
return $value === '' || filter_var($value, FILTER_VALIDATE_URL) ? $value : '';
}
function wp_verify_nonce(string $nonce, string $action): bool {
$GLOBALS['aksrt_nonce_checks'][] = [$nonce, $action];
return $GLOBALS['aksrt_nonce_valid'] && $nonce === 'ok';
}
function aksrt_require_captcha(string $scene, array $payload): bool { return true; }
function get_transient(string $key) { return $GLOBALS['aksrt_transients'][$key] ?? false; }
function set_transient(string $key, $value, int $expiration = 0): bool { $GLOBALS['aksrt_transients'][$key] = $value; return true; }
function delete_transient(string $key): bool { unset($GLOBALS['aksrt_transients'][$key]); return true; }
function wp_check_password(string $password, string $hash): bool { return $hash === 'fixture-hash' && $password === $GLOBALS['aksrt_expected_sms_code']; }
function wp_update_user(array $data) { return (int) ($data['ID'] ?? 0); }
function do_action(...$args): void {}
function add_action(...$args): void {}
function add_filter(string $hook, callable $callback, int $priority = 10, int $accepted_args = 1): void {
$GLOBALS['aksrt_profile_filters'][$hook][] = ['callback' => $callback, 'accepted_args' => $accepted_args];
}
function __(string $text, string $domain = ''): string { return $text; }
function esc_attr(string $value): string { return htmlspecialchars($value, ENT_QUOTES); }
function esc_html_e(string $text, string $domain = ''): void { echo htmlspecialchars($text, ENT_QUOTES); }
function esc_attr_e(string $text, string $domain = ''): void { echo htmlspecialchars($text, ENT_QUOTES); }
function esc_url(string $value): string { return $value; }
function selected($selected, $current = true, bool $echo = true): string { return (string) $selected === (string) $current ? 'selected="selected"' : ''; }
function wp_nonce_field(string $action, string $name): void {}
function get_avatar(...$args): string { return '<img alt="">'; }
function wp_max_upload_size(): int { return 2097152; }
function size_format(int $bytes): string { return (string) $bytes; }
function wp_check_filetype_and_ext(...$args): array { return []; }
function wp_match_mime_types(...$args): bool { return true; }
function wp_getimagesize(...$args): array|false { return false; }
function wp_get_attachment_image_url(...$args): string|false { return false; }
function get_post(...$args) { return null; }
function get_post_meta(...$args) { return ''; }
function wp_delete_attachment(...$args): bool { return true; }
function get_current_screen() { return null; }
require dirname(__DIR__, 2) . '/inc/helpers/aksrt-user-center.php';
}
namespace AKSRT\Sms {
final class SMS {
public static function is_phonenumber(string $phone): bool { return (bool) preg_match('/^1[3-9]\d{9}$/', $phone); }
}
}
namespace AKSRT\Settings {
final class Settings {
public static function get_all(): array { return ['bind_phone' => true]; }
}
}
namespace AKSRT\Auth {
require dirname(__DIR__, 2) . '/src/Auth/ServicePart1b.php';
final class PhoneHarnessService { use ServicePart1b; }
final class Service { use ServicePart1b; }
}
namespace AKSRT\Admin {
require dirname(__DIR__, 2) . '/src/Admin/UserControllerAuthPhoneOperations.php';
final class PhoneOperationsHarness {
use UserControllerAuthPhoneOperations;
public function get_json_params(\WP_REST_Request $request): array { return $request->get_json_params(); }
public function error(string $message, int $status = 400): \WP_REST_Response { return new \WP_REST_Response(['success' => false, 'message' => $message], $status); }
public function success(array $data): \WP_REST_Response { return new \WP_REST_Response(array_merge(['success' => true], $data), 200); }
}
}
namespace {
require dirname(__DIR__, 2) . '/src/User/ProfileAdmin.php';
function check(bool $condition, string $message): void {
if (!$condition) throw new RuntimeException($message);
}
$GLOBALS['aksrt_profile_meta'][7] = [
'aksrt_phone' => '13900000000',
'aksrt_profile_details' => [
'privacy' => 'public', 'gender' => 'female', 'website' => 'https://example.test',
'qq' => '123456', 'wechat' => 'wxid', 'weibo' => 'https://weibo.com/user',
'github' => 'https://github.com/user', 'address' => 'Shanghai',
],
];
$bad_format = \AKSRT\Auth\PhoneHarnessService::bind_phone(['phone' => 'bad'], true);
check(empty($bad_format['success']), 'phone binding accepted malformed number');
$unverified = \AKSRT\Auth\PhoneHarnessService::bind_phone(['phone' => '13800000007'], false);
check(empty($unverified['success']), 'phone binding accepted missing verification');
$GLOBALS['aksrt_profile_meta'][8]['phone_number'] = '13800000008';
$duplicate = \AKSRT\Auth\PhoneHarnessService::bind_phone(['phone' => '13800000008'], true);
check(empty($duplicate['success']), 'phone binding accepted a number owned by another user');
$bound = \AKSRT\Auth\PhoneHarnessService::bind_phone(['phone' => '13800000007'], true);
check(!empty($bound['success']), 'verified phone binding failed');
check(get_user_meta(7, 'phone_number', true) === '13800000007', 'verified phone was not stored in canonical meta');
check(get_user_meta(7, 'aksrt_phone_verified', true) === '1', 'verified marker was not stored');
check(get_user_meta(7, 'aksrt_phone', true) === '13900000000', 'legacy phone key was unexpectedly migrated or rewritten');
$same_phone = \AKSRT\Auth\PhoneHarnessService::bind_phone(['phone' => '13800000007'], true);
check(empty($same_phone['success']), 'binding accepted the current phone as a change');
$phone_to_bind = '13800000010';
$otp_key = 'aksrt_auth_code_' . md5('account_bind_new_phone_7|' . strtolower(trim($phone_to_bind)));
$operations = new \AKSRT\Admin\PhoneOperationsHarness();
$old_phone = '13800000007';
$old_otp_key = 'aksrt_auth_code_' . md5('account_verify_old_phone_7|' . strtolower(trim($old_phone)));
$GLOBALS['aksrt_transients'][$old_otp_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() + 300];
$GLOBALS['aksrt_expected_sms_code'] = '246810';
$wrong_old_code = $operations->user_verify_phone(new WP_REST_Request(['phone' => $old_phone, 'code' => '000000']));
check(empty($wrong_old_code->data['success']), 'REST phone replacement accepted an incorrect old-number code');
check(($GLOBALS['aksrt_transients'][$old_otp_key]['attempts'] ?? 0) === 1, 'incorrect old-number code attempt was not recorded');
$GLOBALS['aksrt_transients'][$old_otp_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() - 1];
$expired_old_code = $operations->user_verify_phone(new WP_REST_Request(['phone' => $old_phone, 'code' => '246810']));
check(empty($expired_old_code->data['success']), 'REST phone replacement accepted an expired old-number code');
check(!isset($GLOBALS['aksrt_transients'][$old_otp_key]), 'expired old-number code was not discarded');
$GLOBALS['aksrt_transients'][$old_otp_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() + 300];
$verified_old_phone = $operations->user_verify_phone(new WP_REST_Request(['phone' => $old_phone, 'code' => '246810']));
check(!empty($verified_old_phone->data['success']), 'REST phone replacement rejected the correct old-number code');
check(!isset($GLOBALS['aksrt_transients'][$old_otp_key]), 'valid old-number code was not consumed');
check(($GLOBALS['aksrt_transients']['aksrt_account_phone_old_7']['phone'] ?? '') === $old_phone, 'old-number proof was not issued for the current account');
$GLOBALS['aksrt_transients'][$otp_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() + 300];
$wrong_code = $operations->user_bind_phone(new WP_REST_Request(['phone' => $phone_to_bind, 'code' => '000000']));
check(empty($wrong_code->data['success']), 'REST phone binding accepted an incorrect SMS code');
check(($GLOBALS['aksrt_transients'][$otp_key]['attempts'] ?? 0) === 1, 'incorrect SMS code attempt was not recorded');
$GLOBALS['aksrt_transients'][$otp_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() - 1];
$expired_code = $operations->user_bind_phone(new WP_REST_Request(['phone' => $phone_to_bind, 'code' => '246810']));
check(empty($expired_code->data['success']), 'REST phone binding accepted an expired SMS code');
check(!isset($GLOBALS['aksrt_transients'][$otp_key]), 'expired SMS code was not discarded');
$duplicate_phone = '13800000008';
$duplicate_otp_key = 'aksrt_auth_code_' . md5('account_bind_new_phone_7|' . strtolower(trim($duplicate_phone)));
$GLOBALS['aksrt_transients'][$duplicate_otp_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() + 300];
$duplicate_code = $operations->user_bind_phone(new WP_REST_Request(['phone' => $duplicate_phone, 'code' => '246810']));
check(empty($duplicate_code->data['success']), 'REST phone binding accepted a number owned by another user');
check(isset($GLOBALS['aksrt_transients'][$duplicate_otp_key]), 'duplicate-number validation consumed the valid SMS code');
$GLOBALS['aksrt_transients'][$otp_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() + 300];
$valid_code = $operations->user_bind_phone(new WP_REST_Request(['phone' => $phone_to_bind, 'code' => '246810']));
check(!empty($valid_code->data['success']), 'REST phone binding rejected the correct SMS code');
check(get_user_meta(7, 'phone_number', true) === $phone_to_bind, 'REST phone binding did not store the canonical phone meta');
check(!isset($GLOBALS['aksrt_transients'][$otp_key]), 'valid SMS code was not consumed');
check(!isset($GLOBALS['aksrt_transients']['aksrt_account_phone_old_7']), 'successful phone change did not consume the old-number proof');
$initial_bind_user_id = 9;
$initial_bind_phone = '13800000011';
$initial_bind_code_key = 'aksrt_auth_code_' . md5('account_bind_new_phone_9|' . strtolower(trim($initial_bind_phone)));
$GLOBALS['aksrt_current_user_id'] = $initial_bind_user_id;
$GLOBALS['aksrt_transients'][$initial_bind_code_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() + 300];
$initial_bind = $operations->user_bind_phone(new WP_REST_Request(['phone' => $initial_bind_phone, 'code' => '246810']));
check(!empty($initial_bind->data['success']), 'first-time phone binding incorrectly required an old-number proof');
check(get_user_meta($initial_bind_user_id, 'phone_number', true) === $initial_bind_phone, 'first-time phone binding did not store the canonical phone meta');
check(!isset($GLOBALS['aksrt_transients'][$initial_bind_code_key]), 'successful first-time phone binding did not consume its SMS code');
$GLOBALS['aksrt_current_user_id'] = 7;
$_POST = [
'aksrt_profile_admin_nonce' => 'ok',
'aksrt_admin_avatar_nonce' => 'ok',
'aksrt_profile_gender' => 'male',
'aksrt_profile_qq' => '7654321',
'aksrt_profile_wechat' => 'new-wechat',
'aksrt_profile_weibo' => 'https://weibo.com/new-user',
'aksrt_profile_github' => 'https://github.com/new-user',
'aksrt_profile_website' => 'https://new.example.test',
'aksrt_profile_address' => 'Beijing',
'phone_number' => '13800000007',
'aksrt_phone_edit_unlocked' => '0',
];
$_FILES = [];
\AKSRT\User\ProfileAdmin::save(7);
$details = aksrt_get_user_profile_details(7);
check($details['privacy'] === 'public', 'admin profile update erased social privacy setting');
check($details['gender'] === 'male' && $details['weibo'] === 'https://weibo.com/new-user' && $details['address'] === 'Beijing', 'admin profile fields did not persist');
check($details['website'] === 'https://new.example.test', 'personal website field did not persist');
$front_save = aksrt_save_user_center_profile(7, ['nickname' => 'Profile User']);
check($front_save === true, 'user-center profile save failed');
$details = aksrt_get_user_profile_details(7);
check($details['weibo'] === 'https://weibo.com/new-user' && $details['address'] === 'Beijing', 'front profile save erased admin-added fields');
$details['privacy'] = 'private';
update_user_meta(7, 'aksrt_profile_details', $details);
$private_view = aksrt_get_visible_user_profile_details(7, 9);
check($private_view['weibo'] === '' && $private_view['address'] === '', 'private social details leaked to another viewer');
$owner_view = aksrt_get_visible_user_profile_details(7, 7);
check($owner_view['weibo'] === 'https://weibo.com/new-user' && $owner_view['address'] === 'Beijing', 'owner cannot read private social details');
$details['privacy'] = 'public';
update_user_meta(7, 'aksrt_profile_details', $details);
$public_view = aksrt_get_visible_user_profile_details(7, 9);
check($public_view['weibo'] === 'https://weibo.com/new-user' && $public_view['address'] === 'Beijing', 'public social details are not visible to another viewer');
\AKSRT\User\ProfileAdmin::init();
$profile_hook = $GLOBALS['aksrt_profile_filters']['user_profile_update_errors'][0] ?? [];
check(($profile_hook['accepted_args'] ?? 0) === 3, 'profile validation must receive all three WordPress hook arguments');
$validate_profile = $profile_hook['callback'];
$_POST = ['aksrt_profile_admin_nonce' => 'ok', 'phone_number' => 'not-a-phone', 'aksrt_phone_edit_unlocked' => '1'];
$invalid_errors = new WP_Error();
$validate_profile($invalid_errors, true, (object) ['ID' => 7]);
check(in_array('aksrt_profile_phone', $invalid_errors->get_error_codes(), true), 'invalid admin phone format was not rejected');
$_POST = ['user_id' => 8, 'aksrt_profile_admin_nonce' => 'ok', 'phone_number' => '13800000008', 'aksrt_phone_edit_unlocked' => '1'];
$GLOBALS['aksrt_capability_checks'] = [];
$GLOBALS['aksrt_nonce_checks'] = [];
$duplicate_errors = new WP_Error();
$validate_profile($duplicate_errors, true, (object) ['ID' => 7]);
check(in_array('aksrt_profile_phone_exists', $duplicate_errors->get_error_codes(), true), 'admin profile accepted a duplicate phone');
check($GLOBALS['aksrt_capability_checks'] === [['edit_user', 7]], 'profile permission check used the posted ID instead of the hook user');
check($GLOBALS['aksrt_nonce_checks'] === [['ok', 'aksrt_profile_admin_nonce_7']], 'profile nonce used the posted ID instead of the hook user');
foreach ([(object) ['ID' => 0], (object) []] as $new_user) {
$_POST = ['user_id' => 7, 'aksrt_profile_admin_nonce' => 'ok', 'phone_number' => 'not-a-phone', 'aksrt_phone_edit_unlocked' => '1'];
$GLOBALS['aksrt_capability_checks'] = [];
$GLOBALS['aksrt_nonce_checks'] = [];
$new_user_errors = new WP_Error();
$validate_profile($new_user_errors, false, $new_user);
check($new_user_errors->get_error_codes() === [], 'a new user without an ID was validated against the posted user');
check($GLOBALS['aksrt_capability_checks'] === [] && $GLOBALS['aksrt_nonce_checks'] === [], 'a missing hook user ID fell back to POST');
}
$_POST = ['aksrt_profile_admin_nonce' => 'wrong', 'phone_number' => 'not-a-phone', 'aksrt_phone_edit_unlocked' => '1'];
$invalid_nonce_errors = new WP_Error();
$validate_profile($invalid_nonce_errors, true, (object) ['ID' => 7]);
check($invalid_nonce_errors->get_error_codes() === ['aksrt_profile_nonce'], 'invalid nonce did not stop profile validation');
$GLOBALS['aksrt_can_edit_user'] = false;
$GLOBALS['aksrt_nonce_checks'] = [];
$denied_errors = new WP_Error();
$validate_profile($denied_errors, true, (object) ['ID' => 7]);
check($denied_errors->get_error_codes() === [] && $GLOBALS['aksrt_nonce_checks'] === [], 'profile validation bypassed the edit-user permission guard');
$GLOBALS['aksrt_can_edit_user'] = true;
$_POST = ['phone_number' => 'not-a-phone', 'aksrt_phone_edit_unlocked' => '1'];
$missing_nonce_errors = new WP_Error();
$validate_profile($missing_nonce_errors, true, (object) ['ID' => 7]);
check($missing_nonce_errors->get_error_codes() === [], 'unrelated profile submissions triggered custom field validation');
$_POST = ['aksrt_profile_admin_nonce' => 'ok', 'phone_number' => get_user_meta(7, 'phone_number', true), 'aksrt_phone_edit_unlocked' => '1'];
$valid_errors = new WP_Error();
$validate_profile($valid_errors, true, (object) ['ID' => 7]);
check($valid_errors->get_error_codes() === [], 'valid stdClass profile data did not pass validation');
$_POST = ['aksrt_profile_admin_nonce' => 'wrong', 'phone_number' => '13800000009', 'aksrt_phone_edit_unlocked' => '1'];
$before_phone = get_user_meta(7, 'phone_number', true);
$before_details = get_user_meta(7, 'aksrt_profile_details', true);
\AKSRT\User\ProfileAdmin::save(7);
check(get_user_meta(7, 'phone_number', true) === $before_phone, 'invalid admin nonce changed the phone');
check(get_user_meta(7, 'aksrt_profile_details', true) === $before_details, 'invalid admin nonce changed profile details');
$_POST = ['aksrt_profile_admin_nonce' => 'ok', 'phone_number' => '13800000009', 'aksrt_phone_edit_unlocked' => '1'];
\AKSRT\User\ProfileAdmin::save(7);
check(get_user_meta(7, 'phone_number', true) === '13800000009', 'unlocked admin phone change was not saved');
check(get_user_meta(7, 'aksrt_phone_verified', true) === '1', 'admin phone change cleared its verified marker');
$_POST['phone_number'] = '';
\AKSRT\User\ProfileAdmin::save(7);
check(get_user_meta(7, 'phone_number', true) === '13800000009', 'blank admin phone unexpectedly cleared the binding');
$before_details = get_user_meta(7, 'aksrt_profile_details', true);
$GLOBALS['aksrt_can_edit_user'] = false;
$_POST = ['aksrt_profile_admin_nonce' => 'ok', 'aksrt_profile_address' => 'Unauthorized change'];
\AKSRT\User\ProfileAdmin::save(7);
$GLOBALS['aksrt_can_edit_user'] = true;
check(get_user_meta(7, 'aksrt_profile_details', true) === $before_details, 'user without edit permission changed profile details');
echo "User profile phone harness passed\n";
}