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);