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>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-30 20:38:00 +08:00
1 parent bc26d62602
commit f9b7997ec2
4 files changed
+74 -11

No files matched your search

+2 -2
View File
@@ -85,8 +85,8 @@ final class ProfileAdmin {
<?php
}
public static function validate(\WP_Error $errors, bool $update, ?\WP_User $user = null): void {
$user_id = $user instanceof \WP_User ? (int) $user->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;
}
@@ -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 '<form[^\n]*data-aksrt-verify-review>/);
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'/);
@@ -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');
@@ -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);