From f9b7997ec2e1e793f53900700f0b3ae1d3fb87f5 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 30 Oct 2026 20:38:00 +0800 Subject: [PATCH] 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 --- src/User/ProfileAdmin.php | 4 +- .../user-features-contract.test.cjs | 9 ++- .../user-profile-phone-contract.test.cjs | 11 ++++ .../user-profile-phone-harness.php | 61 +++++++++++++++++-- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/User/ProfileAdmin.php b/src/User/ProfileAdmin.php index 7e0cc5e6..514127f0 100644 --- a/src/User/ProfileAdmin.php +++ b/src/User/ProfileAdmin.php @@ -85,8 +85,8 @@ final class ProfileAdmin { ID : (int) ($_POST['user_id'] ?? 0); + public static function validate(\WP_Error $errors, bool $update, \stdClass $user): void { + $user_id = (int) ($user->ID ?? 0); if ($user_id <= 0 || !current_user_can('edit_user', $user_id) || empty($_POST[self::PROFILE_NONCE])) { return; } diff --git a/tests/widget-system/user-features-contract.test.cjs b/tests/widget-system/user-features-contract.test.cjs index 06b0ef70..03de8cb8 100644 --- a/tests/widget-system/user-features-contract.test.cjs +++ b/tests/widget-system/user-features-contract.test.cjs @@ -200,6 +200,7 @@ test('native user profile omits ledger tables and renders canonical images eager test('identity marker and profile are editable on the native profile while applications use the review queue', () => { const auth = read('src/User/AuthApply.php'); const verify = read('src/Admin/Native/UsersMenusVerify.php'); + const verifyDialogs = read('src/Admin/Native/UsersMenusVerifyDialogs.php'); const transitions = read('src/Identity/TransitionService.php'); assert.match(auth, /add_action\('edit_user_profile_update', \[\$this, 'save_admin_profile'\]\)/); @@ -214,9 +215,11 @@ test('identity marker and profile are editable on the native profile while appli assert.doesNotMatch(auth, /name="aksrt_identity_(?:application_uuid|type|status|public_name)"/); assert.match(verify, /'general',[\s\S]*?'manual'/); - assert.match(verify, /if \(\$status === 'manual_pending'\)/); - assert.match(verify, /'verified' => __\('通过申请'/); - assert.match(verify, /'rejected' => __\('拒绝申请'/); + assert.match(verify, /use UsersMenusVerifyDialogs;/); + assert.match(verifyDialogs, /\$pending = \$row\['status'\] === 'manual_pending'/); + assert.match(verifyDialogs, /if \(\$pending\) \{\s*echo '/); + assert.match(verifyDialogs, /'verified' => __\('通过申请'/); + assert.match(verifyDialogs, /'rejected' => __\('拒绝申请'/); assert.match(verify, /AuthApply::review\(\$user_id, \$uuid, \$status, \$note, get_current_user_id\(\), \$fields\)/); assert.match(auth, /\['verified', 'rejected'\]/); assert.match(auth, /\(string\) \$application\['status'\] !== 'manual_pending'/); diff --git a/tests/widget-system/user-profile-phone-contract.test.cjs b/tests/widget-system/user-profile-phone-contract.test.cjs index 4b300f01..49481f3b 100644 --- a/tests/widget-system/user-profile-phone-contract.test.cjs +++ b/tests/widget-system/user-profile-phone-contract.test.cjs @@ -14,6 +14,17 @@ test('phone persistence, uniqueness, and native profile saves pass the PHP harne assert.match(output, /User profile phone harness passed/); }); +test('profile validation follows the WordPress stdClass hook contract without a POST user-ID fallback', () => { + const admin = read('src/User/ProfileAdmin.php'); + const validation = admin.slice(admin.indexOf('public static function validate('), admin.indexOf('public static function save(')); + + assert.match(admin, /add_filter\('user_profile_update_errors', \[self::class, 'validate'\], 10, 3\)/); + assert.match(validation, /validate\(\\WP_Error \$errors, bool \$update, \\stdClass \$user\): void/); + assert.match(validation, /\$user_id = \(int\) \(\$user->ID \?\? 0\)/); + assert.doesNotMatch(validation, /instanceof \\WP_User|\$_POST\['user_id'\]/); + assert.match(admin, /render\(\\WP_User \$user\): void/); +}); + test('phone binding, registration, and sign-in share phone_number as the only phone value key', () => { const service = read('src/Auth/Service.php'); const registrationAndBinding = read('src/Auth/ServicePart1b.php'); diff --git a/tests/widget-system/user-profile-phone-harness.php b/tests/widget-system/user-profile-phone-harness.php index 5a537e0f..40cb9543 100644 --- a/tests/widget-system/user-profile-phone-harness.php +++ b/tests/widget-system/user-profile-phone-harness.php @@ -35,7 +35,10 @@ namespace { 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 { return $GLOBALS['aksrt_can_edit_user']; } + 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) { @@ -64,7 +67,10 @@ namespace { $value = trim((string) $value); return $value === '' || filter_var($value, FILTER_VALIDATE_URL) ? $value : ''; } - function wp_verify_nonce(string $nonce, string $action): bool { return $GLOBALS['aksrt_nonce_valid'] && $nonce === 'ok'; } + 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; } @@ -73,7 +79,9 @@ namespace { function wp_update_user(array $data) { return (int) ($data['ID'] ?? 0); } function do_action(...$args): void {} function add_action(...$args): void {} - function add_filter(...$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); } @@ -244,15 +252,56 @@ namespace { $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(); - \AKSRT\User\ProfileAdmin::validate($invalid_errors, true, new WP_User(7)); + $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 = ['aksrt_profile_admin_nonce' => 'ok', 'phone_number' => '13800000008', 'aksrt_phone_edit_unlocked' => '1']; + $_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(); - \AKSRT\User\ProfileAdmin::validate($duplicate_errors, true, new WP_User(7)); + $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);