feat(admin): redesign identity review with dialogs
Replace inline identity review forms with focused detail and review dialogs, improve list filters and caching, and align evidence permissions and deletion feedback. Add contract and runtime regression coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5732be847e
commit
bc26d62602
9 files changed
+2222
-116
No files matched your search
@@ -0,0 +1,271 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = (...segments) => fs.readFileSync(path.join(root, ...segments), 'utf8');
|
||||
let result;
|
||||
function harness() {
|
||||
if (!result) {
|
||||
const run = spawnSync('php', [path.join(__dirname, 'user-auth-admin-harness.php')], { encoding: 'utf8' });
|
||||
assert.equal(run.status, 0, `${run.stderr}${run.stdout}`);
|
||||
assert.equal(run.stderr, '', 'the PHP harness must not emit warnings');
|
||||
result = JSON.parse(run.stdout);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function template(html, id) {
|
||||
const safeId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = html.match(new RegExp(`<template\\b[^>]*\\bid="${safeId}"[^>]*>[\\s\\S]*?<\\/template>`));
|
||||
assert.ok(match, `missing template ${id}`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
const source = {
|
||||
verify: () => read('src', 'Admin', 'Native', 'UsersMenusVerify.php'),
|
||||
dialogs: () => read('src', 'Admin', 'Native', 'UsersMenusVerifyDialogs.php'),
|
||||
menus: () => read('src', 'Admin', 'Native', 'UsersMenus.php'),
|
||||
authApply: () => read('src', 'User', 'AuthApply.php'),
|
||||
applicationRepository: () => read('src', 'Identity', 'ApplicationRepository.php'),
|
||||
};
|
||||
|
||||
test('default pending filters, keyword, status views, list and pagination share one card', () => {
|
||||
const { html } = harness();
|
||||
const cardStart = html.indexOf('class="aksrt-native-card aksrt-native-card--flush aksrt-verify-list-card"');
|
||||
const dialogStart = html.indexOf('<dialog id="aksrt-verify-dialog"');
|
||||
const cardEnd = html.lastIndexOf('</section>', dialogStart);
|
||||
assert.ok(cardStart >= 0 && cardEnd > cardStart, 'verification list card wraps its content');
|
||||
const card = html.slice(cardStart, cardEnd + '</section>'.length);
|
||||
assert.equal((html.match(/aksrt-verify-list-card/g) || []).length, 1);
|
||||
for (const marker of ['class="subsubsub"', 'class="aksrt-native-filters"', 'aksrt-verify-table', 'class="tablenav bottom"']) {
|
||||
assert.ok(card.includes(marker), `${marker} should remain inside the list card`);
|
||||
}
|
||||
assert.match(card, /<table class="widefat fixed striped aksrt-verify-table">/);
|
||||
assert.doesNotMatch(card, /class="wp-list-table/);
|
||||
assert.match(card, /name="status" value="pending"/);
|
||||
assert.match(card, /name="keyword" value="search alert\(1\) &_%"/);
|
||||
assert.match(card, /status=pending&keyword=search\+alert%281%29\+%26_%25/);
|
||||
assert.match(card, /class="current" aria-current="page">待审核/);
|
||||
assert.match(card, /第 3 页,共 <span class="total-pages">3<\/span> 页/);
|
||||
assert.match(card, /name="paged" value="3"/);
|
||||
assert.doesNotMatch(card, /class="aksrt-verify-filter-summary"/);
|
||||
});
|
||||
|
||||
test('rendered user/application data is escaped and row review forms exist only in inert templates', () => {
|
||||
const { html } = harness();
|
||||
assert.match(html, /Applicant <script>alert\(1\)<\/script>/);
|
||||
assert.match(html, /Applicant name <script>alert\(3\)<\/script>/);
|
||||
assert.match(html, /Bio <img src=x onerror=alert\(4\)> & text/);
|
||||
assert.match(html, /Reviewer <img src=x onerror=alert\(2\)>/);
|
||||
assert.doesNotMatch(html, /<script>|<svg\b|<img[^>]*onerror=/i);
|
||||
const outsideTemplates = html.replace(/<template\b[^>]*>[\s\S]*?<\/template>/g, '');
|
||||
assert.doesNotMatch(outsideTemplates, /<form\b[^>]*data-aksrt-verify-review/);
|
||||
assert.equal((html.match(/data-aksrt-verify-review/g) || []).length, 1);
|
||||
assert.equal((html.match(/data-aksrt-verify-delete-form/g) || []).length, 2);
|
||||
assert.match(html, /data-aksrt-verify-view="301"/);
|
||||
assert.match(html, /data-aksrt-verify-view="302"/);
|
||||
});
|
||||
|
||||
test('detail and delete templates bind UUID nonces, and processed records are read-only', () => {
|
||||
const { html } = harness();
|
||||
const pending = template(html, 'aksrt-verify-detail-301');
|
||||
const pendingDelete = template(html, 'aksrt-verify-delete-301');
|
||||
const approved = template(html, 'aksrt-verify-detail-302');
|
||||
const approvedDelete = template(html, 'aksrt-verify-delete-302');
|
||||
assert.match(pending, /name="application_uuid" value="uuid-pending-301"/);
|
||||
assert.match(pending, /name="_wpnonce" value="nonce:aksrt_users_review_verify_uuid-pending-301"/);
|
||||
assert.match(pendingDelete, /name="_wpnonce" value="nonce:aksrt_users_delete_verify_uuid-pending-301"/);
|
||||
assert.match(approvedDelete, /name="_wpnonce" value="nonce:aksrt_users_delete_verify_uuid-approved-302"/);
|
||||
assert.doesNotMatch(approved, /<form\b|data-aksrt-verify-review/);
|
||||
assert.match(approved, /Reviewed safely/);
|
||||
assert.match(approved, /2026-10-30 12:45/);
|
||||
assert.doesNotMatch(pending + pendingDelete + approvedDelete, /aksrt_users_(?:review|delete)_verify_41/);
|
||||
});
|
||||
|
||||
test('review UI exposes both decisions, conditional approval profile fields, and mandatory rejection note metadata', () => {
|
||||
const { html } = harness();
|
||||
const pending = template(html, 'aksrt-verify-detail-301');
|
||||
assert.match(pending, /name="status" value="verified" required/);
|
||||
assert.match(pending, /name="status" value="rejected" required/);
|
||||
assert.match(pending, /data-aksrt-verify-profile hidden disabled/);
|
||||
assert.match(pending, /name="name" maxlength="64"[^>]*required/);
|
||||
assert.match(pending, /<textarea name="description" rows="3">/);
|
||||
assert.match(pending, /name="note" rows="3"[^>]*data-required-error="请填写拒绝原因"/);
|
||||
assert.match(pending, /data-required="拒绝原因(必填)"/);
|
||||
});
|
||||
|
||||
test('proof links are private admin-post URLs protected by per-attachment nonces', () => {
|
||||
const { html } = harness();
|
||||
const pending = template(html, 'aksrt-verify-detail-301');
|
||||
const link = pending.match(/<a href="([^"]+)" target="_blank" rel="noopener noreferrer"><img/);
|
||||
assert.ok(link, 'evidence is displayed through a controlled link');
|
||||
const url = new URL(link[1].replaceAll('&', '&'));
|
||||
assert.equal(url.origin, 'https://example.test');
|
||||
assert.equal(url.pathname, '/wp-admin/admin-post.php');
|
||||
assert.equal(url.searchParams.get('action'), 'aksrt_verify_evidence');
|
||||
assert.equal(url.searchParams.get('attachment'), '1001');
|
||||
assert.equal(url.searchParams.get('_wpnonce'), 'nonce:aksrt_verify_evidence_1001');
|
||||
assert.doesNotMatch(link[1], /uploads|wp-content/i);
|
||||
});
|
||||
|
||||
test('empty states, GMT conversion, clamped offset, prepared filters and batch caches are real trait behavior', () => {
|
||||
const data = harness();
|
||||
assert.match(data.pending_empty, /暂无待审核申请/);
|
||||
assert.match(data.search_empty, /没有符合条件的认证申请/);
|
||||
assert.match(data.search_empty, /请调整搜索关键词或切换状态/);
|
||||
assert.match(data.html, /2026-10-30 11:30/);
|
||||
assert.match(data.html, /2026-10-29 10:10/);
|
||||
assert.equal(data.page_prepared.length, 3, 'status count, total and list queries are prepared once each');
|
||||
const like = String.raw`%search alert(1) &\_\%%`;
|
||||
for (const query of data.page_prepared) {
|
||||
assert.deepEqual(query.params.slice(0, 2), ['general', 'manual']);
|
||||
assert.match(query.sql, /a\.type = %s AND a\.channel = %s/);
|
||||
assert.ok(query.params.includes(like), 'keyword wildcard characters are escaped before prepare');
|
||||
}
|
||||
const listQuery = data.page_prepared.find(({ sql }) => /LIMIT %d OFFSET %d/.test(sql));
|
||||
assert.ok(listQuery, 'the list query has typed integer pagination placeholders');
|
||||
assert.deepEqual(listQuery.params.slice(-2), [20, 40], 'requested page 999 is clamped to the final page before OFFSET');
|
||||
assert.equal(listQuery.params[2], 'manual_pending', 'the pending view filters the stored status');
|
||||
const groupedQuery = data.page_prepared.find(({ sql }) => /GROUP BY a.status/.test(sql));
|
||||
assert.doesNotMatch(groupedQuery.sql, /a\.status = %s/, 'status view counts span every status for the same keyword');
|
||||
assert.equal(data.page_executed.filter((sql) => /application_uuid\s*=/.test(sql)).length, 0, 'rendering does not run per-row find queries');
|
||||
assert.deepEqual(data.cache_calls, [[41, 42, 9]], 'all applicant/reviewer user ids are primed in one batch');
|
||||
assert.deepEqual(data.meta_cache_calls, [['post', [1001, 1002]]], 'evidence post meta is primed in one deduplicated batch');
|
||||
});
|
||||
|
||||
test('review handler checks permission and UUID nonce, sanitizes payload, and preserves independent list filters', () => {
|
||||
const data = harness();
|
||||
assert.equal(data.permission_denied, true);
|
||||
assert.equal(data.bad_nonce_rejected, true);
|
||||
assert.deepEqual(data.review_nonce_calls[0], ['aksrt_users_review_verify_review-uuid-401', '_wpnonce']);
|
||||
assert.deepEqual(data.review_call, {
|
||||
user_id: 41,
|
||||
application_uuid: 'review-uuid-401',
|
||||
status: 'rejected',
|
||||
note: 'Rejected reason & explain',
|
||||
reviewer_id: 7,
|
||||
profile_fields: { name: 'Ignored', description: 'Ignored description' },
|
||||
});
|
||||
assert.deepEqual(data.review_error_call, {
|
||||
user_id: 41,
|
||||
application_uuid: 'review-uuid-401',
|
||||
status: 'verified',
|
||||
note: '',
|
||||
reviewer_id: 7,
|
||||
profile_fields: { name: 'Safe name', description: 'Safe description' },
|
||||
});
|
||||
assert.deepEqual(data.review_redirect, {
|
||||
page: 'aksrt_user_auth',
|
||||
aksrt_users_msg: 'verify_updated',
|
||||
status: 'approved',
|
||||
keyword: 'review search & phrase',
|
||||
paged: '4',
|
||||
});
|
||||
assert.deepEqual(data.review_error_redirect, {
|
||||
page: 'aksrt_user_auth',
|
||||
aksrt_users_msg: 'verify_failed',
|
||||
status: 'rejected',
|
||||
keyword: 'keep this',
|
||||
paged: '2',
|
||||
aksrt_verify_error: '<script>review unavailable</script>',
|
||||
});
|
||||
});
|
||||
|
||||
test('delete handler cleans attachments and events only after successful delete, without revoking auth projection', () => {
|
||||
const data = harness();
|
||||
assert.deepEqual(data.delete_success_redirect, {
|
||||
page: 'aksrt_user_auth',
|
||||
aksrt_users_msg: 'verify_deleted',
|
||||
status: 'rejected',
|
||||
keyword: 'delete search',
|
||||
paged: '6',
|
||||
});
|
||||
assert.deepEqual(data.delete_success_calls, [
|
||||
{ table: 'wp_aksrt_identity_application', data: { id: 501, type: 'general', channel: 'manual' }, format: ['%d', '%s', '%s'] },
|
||||
{ table: 'wp_aksrt_identity_event', data: { application_id: 501 }, format: ['%d'] },
|
||||
]);
|
||||
assert.deepEqual(data.delete_success_attachments, [[1001, true], [1002, true]]);
|
||||
assert.deepEqual(data.delete_auth_projection, { auth: 1, auth_info: { name: 'Already verified', desc: 'kept' } });
|
||||
assert.deepEqual(data.delete_user_meta_calls, []);
|
||||
assert.deepEqual(data.delete_failure_redirect, {
|
||||
page: 'aksrt_user_auth',
|
||||
aksrt_users_msg: 'verify_failed',
|
||||
status: 'pending',
|
||||
keyword: 'delete failure',
|
||||
paged: '1',
|
||||
aksrt_verify_error: '认证记录删除失败',
|
||||
});
|
||||
assert.equal(data.delete_failure_calls.length, 1, 'event rows are untouched after repository deletion fails');
|
||||
assert.match(data.delete_failure_calls[0].table, /identity_application/);
|
||||
assert.deepEqual(data.delete_failure_attachments, []);
|
||||
});
|
||||
|
||||
test('proof listing and serving honor both reviewer capabilities without exposing other users’ evidence', () => {
|
||||
const { evidence_permissions: permissions } = harness();
|
||||
for (const role of ['manager', 'reviewer']) {
|
||||
assert.equal(permissions[role].admin_items, 1);
|
||||
assert.equal(permissions[role].owner_items, 0);
|
||||
assert.equal(permissions[role].serve_result, '证明材料不存在', 'authorized users reach the file check');
|
||||
}
|
||||
assert.deepEqual(permissions.outsider, {
|
||||
admin_items: 0, owner_items: 0, serve_result: '您没有权限查看该证明材料',
|
||||
});
|
||||
assert.deepEqual(permissions.owner, {
|
||||
admin_items: 0, owner_items: 1, serve_result: '证明材料不存在',
|
||||
});
|
||||
});
|
||||
|
||||
test('event cleanup failure reports partial deletion instead of a false success', () => {
|
||||
const data = harness();
|
||||
assert.equal(data.delete_event_failure_calls.length, 2);
|
||||
assert.deepEqual(data.delete_event_failure_redirect, {
|
||||
page: 'aksrt_user_auth',
|
||||
aksrt_users_msg: 'verify_failed',
|
||||
status: 'rejected',
|
||||
keyword: 'delete search',
|
||||
paged: '6',
|
||||
aksrt_verify_error: '认证申请记录已删除,但审核事件清理失败。',
|
||||
});
|
||||
});
|
||||
|
||||
test('assets are restricted to the authentication submenu and the stylesheet follows native UI', () => {
|
||||
const data = harness();
|
||||
assert.deepEqual(data.wrong_hook_assets, []);
|
||||
assert.equal(data.auth_page_assets.length, 2);
|
||||
assert.deepEqual(data.auth_page_assets[0].slice(0, 4), [
|
||||
'style', 'aksrt-admin-user-auth', 'https://example.test/theme/assets/css/admin/user-auth.css', ['aksrt-admin-native-ui'],
|
||||
]);
|
||||
assert.deepEqual(data.auth_page_assets[1].slice(0, 4), [
|
||||
'script', 'aksrt-admin-user-auth', 'https://example.test/theme/assets/js/admin-user-auth.js', [],
|
||||
]);
|
||||
assert.match(source.verify(), /if \(\$hook !== 'users_page_aksrt_user_auth'\) return/);
|
||||
assert.match(source.menus(), /add_action\('admin_enqueue_scripts', \[\$this, 'enqueue_verify_assets'\]\)/);
|
||||
});
|
||||
|
||||
test('the real AuthApply contract requires valid review data and a compare-and-swap from manual_pending', () => {
|
||||
const auth = source.authApply();
|
||||
const repository = source.applicationRepository();
|
||||
assert.match(auth, /if \(!in_array\(\$status, \['verified', 'rejected'\], true\)\)/);
|
||||
assert.match(auth, /if \(\$status === 'rejected' && \$note === ''\)/);
|
||||
assert.match(auth, /\(int\) \$application\['user_id'\] !== \$user_id/);
|
||||
assert.match(auth, /\(string\) \$application\['status'\] !== 'manual_pending'/);
|
||||
assert.match(auth, /\$repo->transition\(\(int\) \$application\['id'\], 'manual_pending', \$status/);
|
||||
assert.match(auth, /if \(!\$updated\) \{\s*return new \\WP_Error\('aksrt_auth_conflict'/);
|
||||
assert.match(repository, /\['id' => \$id, 'type' => 'general', 'channel' => 'manual', 'status' => \$expected\]/);
|
||||
});
|
||||
|
||||
test('review and deletion nonces use application UUIDs only, while failure notices are escaped by the shell', () => {
|
||||
const verify = source.verify();
|
||||
const dialogs = source.dialogs();
|
||||
const menus = source.menus();
|
||||
assert.match(verify, /check_admin_referer\('aksrt_users_review_verify_' \. \$uuid\)/);
|
||||
assert.match(verify, /check_admin_referer\('aksrt_users_delete_verify_' \. \$uuid\)/);
|
||||
assert.doesNotMatch(verify, /check_admin_referer\('aksrt_users_(?:review|delete)_verify_' \. \$user_id\)/);
|
||||
assert.match(dialogs, /wp_nonce_field\(\$action \. '_' \. \(string\) \$row\['application_uuid'\]\)/);
|
||||
assert.match(menus, /sanitize_text_field\(\(string\) wp_unslash\(\$_GET\['aksrt_verify_error'\]\)\)/);
|
||||
assert.match(menus, /esc_html\(\$message\)/);
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
<?php
|
||||
/** WordPress-free harness for the native identity admin render and POST handlers. */
|
||||
namespace AKSRT\Core {
|
||||
final class DBMigrator {
|
||||
public static function table($key) { return 'wp_aksrt_' . $key; }
|
||||
}
|
||||
}
|
||||
namespace AKSRT\User {
|
||||
final class AuthApply {
|
||||
public static $next_result = null;
|
||||
public static function review($user_id, $application_uuid, $status, $note, $reviewer_id, $profile_fields) {
|
||||
$GLOBALS['review_calls'][] = compact('user_id', 'application_uuid', 'status', 'note', 'reviewer_id', 'profile_fields');
|
||||
return self::$next_result;
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__);
|
||||
define('ARRAY_A', 'ARRAY_A');
|
||||
$root = dirname(__DIR__, 2);
|
||||
define('AKSRT_DIR', $root);
|
||||
define('AKSRT_URI', 'https://example.test/theme');
|
||||
|
||||
class WP_Error {
|
||||
public function __construct(public $code, public $message) {}
|
||||
public function get_error_message() { return $this->message; }
|
||||
}
|
||||
class HarnessWpDie extends \RuntimeException {}
|
||||
class HarnessRedirect extends \RuntimeException {}
|
||||
class WP_User {
|
||||
public function __construct(public $ID, public $display_name) {}
|
||||
}
|
||||
|
||||
function __($text, $domain = '') { return (string) $text; }
|
||||
function esc_html($text) { return htmlspecialchars((string) $text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
||||
function esc_attr($text) { return esc_html($text); }
|
||||
function esc_textarea($text) { return esc_html($text); }
|
||||
function esc_html__($text, $domain = '') { return esc_html($text); }
|
||||
function esc_attr__($text, $domain = '') { return esc_attr($text); }
|
||||
function esc_url($url) {
|
||||
$url = (string) $url;
|
||||
return preg_match('/^\s*javascript:/i', $url) ? '' : esc_attr($url);
|
||||
}
|
||||
function admin_url($path = '') { return 'https://example.test/wp-admin/' . ltrim((string) $path, '/'); }
|
||||
function get_edit_user_link($id) { return admin_url('user-edit.php?user_id=' . abs((int) $id)); }
|
||||
function get_avatar_url($id, $args = []) { return 'https://example.test/avatar.png?user=' . abs((int) $id); }
|
||||
function get_userdata($id) {
|
||||
$id = (int) $id;
|
||||
return isset($GLOBALS['users'][$id]) ? new WP_User($id, $GLOBALS['users'][$id]) : false;
|
||||
}
|
||||
function current_user_can($capability, ...$args) { return !empty($GLOBALS['caps'][$capability]); }
|
||||
function get_current_user_id() { return $GLOBALS['current_user_id'] ?? 7; }
|
||||
function is_user_logged_in() { return get_current_user_id() > 0; }
|
||||
function get_attached_file($id) { return false; }
|
||||
function get_post_mime_type($id) { return 'image/png'; }
|
||||
function delete_user_meta($user_id, $key) {
|
||||
$GLOBALS['delete_user_meta_calls'][] = [(int) $user_id, (string) $key];
|
||||
unset($GLOBALS['user_meta'][(int) $user_id][(string) $key]);
|
||||
return true;
|
||||
}
|
||||
function update_user_meta($user_id, $key, $value) {
|
||||
$GLOBALS['user_meta'][(int) $user_id][(string) $key] = $value;
|
||||
return true;
|
||||
}
|
||||
function wp_die($message = '') { throw new HarnessWpDie((string) $message); }
|
||||
function wp_unslash($value) { return $value; }
|
||||
function absint($value) { return abs((int) $value); }
|
||||
function sanitize_key($text) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $text)) ?? ''; }
|
||||
function sanitize_text_field($text) {
|
||||
$text = strip_tags((string) $text);
|
||||
$text = preg_replace('/[\r\n\t ]+/', ' ', $text) ?? '';
|
||||
return trim($text);
|
||||
}
|
||||
function sanitize_textarea_field($text) {
|
||||
$text = strip_tags((string) $text);
|
||||
$text = preg_replace('/[\r\t ]+/', ' ', $text) ?? '';
|
||||
$text = preg_replace('/\n{3,}/', "\n\n", $text) ?? '';
|
||||
return trim($text);
|
||||
}
|
||||
function is_wp_error($value) { return $value instanceof WP_Error; }
|
||||
function number_format_i18n($number) { return number_format((float) $number); }
|
||||
function get_date_from_gmt($date, $format = 'Y-m-d H:i:s') {
|
||||
if ((string) $date === '') return '';
|
||||
$utc = new DateTimeImmutable((string) $date, new DateTimeZone('UTC'));
|
||||
return $utc->setTimezone(new DateTimeZone('Asia/Shanghai'))->format($format);
|
||||
}
|
||||
function wp_create_nonce($action) { return 'nonce:' . (string) $action; }
|
||||
function wp_nonce_field($action, $name = '_wpnonce', $referer = true, $display = true) {
|
||||
$html = '<input type="hidden" name="' . esc_attr($name) . '" value="' . esc_attr(wp_create_nonce($action)) . '">';
|
||||
if ($display) echo $html;
|
||||
return $html;
|
||||
}
|
||||
function check_admin_referer($action, $query_arg = '_wpnonce') {
|
||||
$GLOBALS['checked_nonces'][] = [$action, $query_arg];
|
||||
$provided = (string) ($_POST[$query_arg] ?? '');
|
||||
if (!hash_equals(wp_create_nonce($action), $provided)) {
|
||||
throw new HarnessWpDie('nonce verification failed');
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
function add_query_arg($key, $value = '', $url = '') {
|
||||
if (is_array($key)) {
|
||||
$args = $key;
|
||||
$url = (string) $value;
|
||||
} else {
|
||||
$args = [$key => $value];
|
||||
if ($url === '') $url = (string) ($_SERVER['REQUEST_URI'] ?? '');
|
||||
}
|
||||
$parts = parse_url((string) $url) ?: [];
|
||||
parse_str($parts['query'] ?? '', $query);
|
||||
foreach ($args as $name => $item) $query[$name] = $item;
|
||||
$base = '';
|
||||
if (isset($parts['scheme'])) $base .= $parts['scheme'] . '://';
|
||||
if (isset($parts['host'])) $base .= $parts['host'];
|
||||
if (isset($parts['port'])) $base .= ':' . $parts['port'];
|
||||
$base .= $parts['path'] ?? '';
|
||||
return $base . ($query !== [] ? '?' . http_build_query($query) : '') . (isset($parts['fragment']) ? '#' . $parts['fragment'] : '');
|
||||
}
|
||||
function cache_users($ids) { $GLOBALS['cache_calls'][] = array_values($ids); }
|
||||
function update_meta_cache($type, $ids) { $GLOBALS['meta_cache_calls'][] = [$type, array_values($ids)]; }
|
||||
function get_post_meta($id, $key, $single = false) { return $GLOBALS['post_meta'][(int) $id][$key] ?? ''; }
|
||||
function get_post_type($id) { return in_array((int) $id, $GLOBALS['attachment_ids'], true) ? 'attachment' : false; }
|
||||
function wp_delete_attachment($id, $force = false) {
|
||||
$GLOBALS['deleted_attachments'][] = [(int) $id, (bool) $force];
|
||||
return true;
|
||||
}
|
||||
function wp_enqueue_style(...$args) { $GLOBALS['assets'][] = ['style', ...$args]; }
|
||||
function wp_enqueue_script(...$args) { $GLOBALS['assets'][] = ['script', ...$args]; }
|
||||
function esc_html_e($text, $domain = '') { echo esc_html($text); }
|
||||
|
||||
final class FakeWpdb {
|
||||
public $users = 'wp_users';
|
||||
public $prepared = [];
|
||||
public $executed = [];
|
||||
public $delete_calls = [];
|
||||
public $total = 45;
|
||||
public $application_delete_result = 1;
|
||||
public $event_delete_result = 1;
|
||||
public $applications = [];
|
||||
|
||||
public function prepare($sql, ...$params) {
|
||||
$params = count($params) === 1 && is_array($params[0]) ? $params[0] : $params;
|
||||
$this->prepared[] = ['sql' => (string) $sql, 'params' => $params];
|
||||
return (string) $sql;
|
||||
}
|
||||
public function esc_like($text) { return addcslashes((string) $text, '_%\\'); }
|
||||
public function get_var($sql) {
|
||||
$this->executed[] = (string) $sql;
|
||||
return str_contains((string) $sql, 'SELECT COUNT(*)') ? $this->total : null;
|
||||
}
|
||||
public function get_results($sql, $format = null) {
|
||||
$this->executed[] = (string) $sql;
|
||||
if (str_contains((string) $sql, 'GROUP BY a.status')) {
|
||||
return [
|
||||
['status' => 'manual_pending', 'total' => 2],
|
||||
['status' => 'verified', 'total' => 1],
|
||||
['status' => 'rejected', 'total' => 1],
|
||||
];
|
||||
}
|
||||
if (str_contains((string) $sql, 'LIMIT %d OFFSET %d')) return $GLOBALS['render_rows'];
|
||||
return [];
|
||||
}
|
||||
public function get_row($sql, $format = null) {
|
||||
$this->executed[] = (string) $sql;
|
||||
$query = $this->prepared[count($this->prepared) - 1] ?? ['params' => []];
|
||||
$requested_uuid = (string) ($query['params'][0] ?? '');
|
||||
foreach ($this->applications as $application) {
|
||||
if (
|
||||
str_contains((string) $sql, 'application_uuid = %s')
|
||||
&& str_contains((string) $sql, 'identity_application')
|
||||
&& (string) ($application['application_uuid'] ?? '') === $requested_uuid
|
||||
) {
|
||||
return $application;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
public function delete($table, $data, $format = null) {
|
||||
$this->delete_calls[] = ['table' => $table, 'data' => $data, 'format' => $format];
|
||||
if (str_contains((string) $table, 'identity_application')) return $this->application_delete_result;
|
||||
if (str_contains((string) $table, 'identity_event')) return $this->event_delete_result;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
require $root . '/src/Admin/Native/NativeUi.php';
|
||||
require $root . '/src/Admin/Native/AdminListPagination.php';
|
||||
require $root . '/src/Identity/ApplicationRepository.php';
|
||||
require $root . '/src/Identity/EvidenceService.php';
|
||||
require $root . '/src/Identity/EventRepository.php';
|
||||
require $root . '/src/Admin/Native/UsersMenusVerifyDialogs.php';
|
||||
require $root . '/src/Admin/Native/UsersMenusVerify.php';
|
||||
|
||||
final class VerifyAdminProbe {
|
||||
use \AKSRT\Admin\Native\UsersMenusVerify;
|
||||
public const CAPABILITY = 'manage_options';
|
||||
public $cap_checks = 0;
|
||||
private function ensure_cap(): void {
|
||||
$this->cap_checks++;
|
||||
if (!current_user_can('manage_options')) wp_die('capability denied');
|
||||
}
|
||||
private function flash(): void {}
|
||||
private function redirect(string $page, string $message, array $extra = []): void {
|
||||
$url = add_query_arg(array_merge(['page' => $page, 'aksrt_users_msg' => $message], $extra), admin_url('users.php'));
|
||||
throw new HarnessRedirect($url);
|
||||
}
|
||||
public function invoke(string $method, ...$args) { return $this->$method(...$args); }
|
||||
public function page(): string {
|
||||
ob_start();
|
||||
$this->render_verify_page();
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
public function html(string $method, ...$args): string {
|
||||
ob_start();
|
||||
$this->$method(...$args);
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
|
||||
$GLOBALS['users'] = [
|
||||
41 => 'Applicant <script>alert(1)</script>',
|
||||
42 => 'Approved User',
|
||||
9 => 'Reviewer <img src=x onerror=alert(2)>',
|
||||
];
|
||||
$GLOBALS['caps'] = ['manage_options' => true, 'aksrt_review_identity' => true];
|
||||
$GLOBALS['post_meta'] = [
|
||||
1001 => [
|
||||
'_aksrt_verify_evidence_owner' => 41,
|
||||
'_aksrt_identity_application_uuid' => 'uuid-pending-301',
|
||||
],
|
||||
1002 => [
|
||||
'_aksrt_verify_evidence_owner' => 42,
|
||||
'_aksrt_identity_application_uuid' => 'uuid-approved-302',
|
||||
],
|
||||
];
|
||||
$GLOBALS['attachment_ids'] = [1001, 1002];
|
||||
$GLOBALS['deleted_attachments'] = [];
|
||||
$GLOBALS['cache_calls'] = [];
|
||||
$GLOBALS['meta_cache_calls'] = [];
|
||||
$GLOBALS['checked_nonces'] = [];
|
||||
$GLOBALS['review_calls'] = [];
|
||||
$GLOBALS['assets'] = [];
|
||||
$GLOBALS['delete_user_meta_calls'] = [];
|
||||
$GLOBALS['user_meta'] = [41 => ['auth' => 1, 'auth_info' => ['name' => 'Already verified', 'desc' => 'kept']]];
|
||||
$GLOBALS['render_rows'] = [
|
||||
[
|
||||
'id' => 301,
|
||||
'application_uuid' => 'uuid-pending-301',
|
||||
'user_id' => 41,
|
||||
'status' => 'manual_pending',
|
||||
'public_name' => 'Applicant name <script>alert(3)</script>',
|
||||
'description' => 'Bio <img src=x onerror=alert(4)> & text',
|
||||
'manual_reason' => 'Reason <svg onload=alert(5)>',
|
||||
'review_note' => '',
|
||||
'reviewer_id' => 0,
|
||||
'evidence_ids' => '[1001]',
|
||||
'created_at' => '2026-10-30 03:30:00',
|
||||
'completed_at' => null,
|
||||
'display_name' => 'Applicant <script>alert(1)</script>',
|
||||
'user_login' => 'applicant"<script>login</script>',
|
||||
'user_email' => 'applicant"<img src=x onerror=alert(6)>@example.test',
|
||||
],
|
||||
[
|
||||
'id' => 302,
|
||||
'application_uuid' => 'uuid-approved-302',
|
||||
'user_id' => 42,
|
||||
'status' => 'verified',
|
||||
'public_name' => 'Approved name',
|
||||
'description' => 'Approved bio',
|
||||
'manual_reason' => 'Applied earlier',
|
||||
'review_note' => 'Reviewed safely',
|
||||
'reviewer_id' => 9,
|
||||
'evidence_ids' => '[1002,1001]',
|
||||
'created_at' => '2026-10-29 02:10:00',
|
||||
'completed_at' => '2026-10-30 04:45:00',
|
||||
'display_name' => 'Approved User',
|
||||
'user_login' => 'approved_user',
|
||||
'user_email' => 'approved@example.test',
|
||||
],
|
||||
];
|
||||
|
||||
$wpdb = new FakeWpdb();
|
||||
|
||||
$probe = new VerifyAdminProbe();
|
||||
$_GET = [
|
||||
'page' => 'aksrt_user_auth',
|
||||
'keyword' => 'search <script>alert(1)</script> &_%',
|
||||
'paged' => '999',
|
||||
];
|
||||
$html = $probe->page();
|
||||
$page_prepared = $wpdb->prepared;
|
||||
$page_executed = $wpdb->executed;
|
||||
$page_cache_calls = $GLOBALS['cache_calls'];
|
||||
$page_meta_cache_calls = $GLOBALS['meta_cache_calls'];
|
||||
|
||||
$pending_empty = $probe->html('render_verify_table', [], ['status' => 'pending', 'keyword' => ''], 1);
|
||||
$search_empty = $probe->html('render_verify_table', [], ['status' => 'approved', 'keyword' => 'no match'], 1);
|
||||
|
||||
$GLOBALS['assets'] = [];
|
||||
$probe->enqueue_verify_assets('users_page_aksrt_messages');
|
||||
$wrong_hook_assets = $GLOBALS['assets'];
|
||||
$probe->enqueue_verify_assets('users_page_aksrt_user_auth');
|
||||
$auth_page_assets = $GLOBALS['assets'];
|
||||
|
||||
$evidence_service = new \AKSRT\Identity\EvidenceService();
|
||||
$evidence_application = ['user_id' => 41, 'application_uuid' => 'uuid-pending-301', 'evidence_ids' => [1001]];
|
||||
$evidence_permissions = [];
|
||||
foreach ([
|
||||
'manager' => [['manage_options' => true], 7],
|
||||
'reviewer' => [['aksrt_review_identity' => true], 7],
|
||||
'outsider' => [[], 7],
|
||||
'owner' => [[], 41],
|
||||
] as $role => [$caps, $current_user]) {
|
||||
$GLOBALS['caps'] = $caps;
|
||||
$GLOBALS['current_user_id'] = $current_user;
|
||||
$evidence_permissions[$role] = [
|
||||
'admin_items' => count($evidence_service->items($evidence_application, true)),
|
||||
'owner_items' => count($evidence_service->items($evidence_application)),
|
||||
];
|
||||
$_GET = ['attachment' => 1001];
|
||||
$_POST = ['_wpnonce' => wp_create_nonce('aksrt_verify_evidence_1001')];
|
||||
try {
|
||||
$evidence_service->serve();
|
||||
} catch (HarnessWpDie $error) {
|
||||
$evidence_permissions[$role]['serve_result'] = $error->getMessage();
|
||||
}
|
||||
}
|
||||
$GLOBALS['current_user_id'] = 7;
|
||||
$GLOBALS['caps'] = ['manage_options' => true, 'aksrt_review_identity' => true];
|
||||
$GLOBALS['checked_nonces'] = [];
|
||||
|
||||
$wpdb->prepared = [];
|
||||
$wpdb->executed = [];
|
||||
$wpdb->applications = [[
|
||||
'id' => 401,
|
||||
'application_uuid' => 'review-uuid-401',
|
||||
'user_id' => 41,
|
||||
'type' => 'general',
|
||||
'channel' => 'manual',
|
||||
'status' => 'manual_pending',
|
||||
'public_name' => 'Before review',
|
||||
'provider_result_summary' => 'Before bio',
|
||||
'manual_reason' => 'Manual reason',
|
||||
'review_note' => '',
|
||||
'reviewer_id' => 0,
|
||||
'evidence_ids' => '[1001]',
|
||||
'created_at' => '2026-10-30 03:30:00',
|
||||
'completed_at' => null,
|
||||
'updated_at' => '2026-10-30 03:30:00',
|
||||
]];
|
||||
$_POST = [
|
||||
'application_uuid' => 'review-uuid-401',
|
||||
'user_id' => '41',
|
||||
'status' => 'REJECTED!',
|
||||
'note' => 'Rejected <script>reason</script> & explain',
|
||||
'name' => 'Ignored <img src=x onerror=alert(1)>',
|
||||
'description' => 'Ignored <b>description</b>',
|
||||
'filter_status' => 'approved',
|
||||
'keyword' => 'review <script>search</script> & phrase',
|
||||
'paged' => '4',
|
||||
'_wpnonce' => wp_create_nonce('aksrt_users_review_verify_review-uuid-401'),
|
||||
];
|
||||
\AKSRT\User\AuthApply::$next_result = ['status' => 'rejected'];
|
||||
try {
|
||||
$probe->handle_review_verify();
|
||||
} catch (HarnessRedirect $redirect) {
|
||||
parse_str((string) parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
|
||||
$review_redirect = $query;
|
||||
}
|
||||
$review_call = $GLOBALS['review_calls'][0] ?? null;
|
||||
$review_nonce_calls = $GLOBALS['checked_nonces'];
|
||||
|
||||
$GLOBALS['review_calls'] = [];
|
||||
$wpdb->prepared = [];
|
||||
$_POST = [
|
||||
'application_uuid' => 'review-uuid-401',
|
||||
'user_id' => '41',
|
||||
'status' => 'verified',
|
||||
'note' => '',
|
||||
'name' => 'Safe name',
|
||||
'description' => 'Safe description',
|
||||
'filter_status' => 'rejected',
|
||||
'keyword' => 'keep this',
|
||||
'paged' => '2',
|
||||
'_wpnonce' => wp_create_nonce('aksrt_users_review_verify_review-uuid-401'),
|
||||
];
|
||||
\AKSRT\User\AuthApply::$next_result = new WP_Error('review_failed', '<script>review unavailable</script>');
|
||||
try {
|
||||
$probe->handle_review_verify();
|
||||
} catch (HarnessRedirect $redirect) {
|
||||
parse_str((string) parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
|
||||
$review_error_redirect = $query;
|
||||
}
|
||||
|
||||
$review_error_call = $GLOBALS['review_calls'][0] ?? null;
|
||||
|
||||
$GLOBALS['caps'] = ['manage_options' => false, 'aksrt_review_identity' => false];
|
||||
$permission_denied = false;
|
||||
try {
|
||||
$probe->handle_review_verify();
|
||||
} catch (HarnessWpDie $error) {
|
||||
$permission_denied = $error->getMessage() === '权限不足';
|
||||
}
|
||||
$GLOBALS['caps'] = ['manage_options' => true, 'aksrt_review_identity' => true];
|
||||
|
||||
$_POST = [
|
||||
'application_uuid' => 'review-uuid-401',
|
||||
'_wpnonce' => wp_create_nonce('aksrt_users_review_verify-wrong-uuid'),
|
||||
];
|
||||
$bad_nonce_rejected = false;
|
||||
try {
|
||||
$probe->handle_review_verify();
|
||||
} catch (HarnessWpDie $error) {
|
||||
$bad_nonce_rejected = $error->getMessage() === 'nonce verification failed';
|
||||
}
|
||||
|
||||
$delete_application = [
|
||||
'id' => 501,
|
||||
'application_uuid' => 'delete-uuid-501',
|
||||
'user_id' => 41,
|
||||
'type' => 'general',
|
||||
'channel' => 'manual',
|
||||
'status' => 'verified',
|
||||
'public_name' => 'Existing identity',
|
||||
'provider_result_summary' => 'Existing description',
|
||||
'manual_reason' => 'Manual reason',
|
||||
'review_note' => 'Approved',
|
||||
'reviewer_id' => 9,
|
||||
'evidence_ids' => '[1001,1002]',
|
||||
'created_at' => '2026-10-29 02:10:00',
|
||||
'completed_at' => '2026-10-30 04:45:00',
|
||||
'updated_at' => '2026-10-30 04:45:00',
|
||||
];
|
||||
$wpdb->applications = [$delete_application];
|
||||
$wpdb->prepared = [];
|
||||
$wpdb->executed = [];
|
||||
$wpdb->delete_calls = [];
|
||||
$wpdb->application_delete_result = 1;
|
||||
$GLOBALS['deleted_attachments'] = [];
|
||||
$GLOBALS['user_meta'][41] = ['auth' => 1, 'auth_info' => ['name' => 'Already verified', 'desc' => 'kept']];
|
||||
$GLOBALS['delete_user_meta_calls'] = [];
|
||||
$_POST = [
|
||||
'application_uuid' => 'delete-uuid-501',
|
||||
'filter_status' => 'rejected',
|
||||
'keyword' => 'delete search',
|
||||
'paged' => '6',
|
||||
'_wpnonce' => wp_create_nonce('aksrt_users_delete_verify_delete-uuid-501'),
|
||||
];
|
||||
try {
|
||||
$probe->handle_delete_verify();
|
||||
} catch (HarnessRedirect $redirect) {
|
||||
parse_str((string) parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
|
||||
$delete_success_redirect = $query;
|
||||
}
|
||||
$delete_success_calls = $wpdb->delete_calls;
|
||||
$delete_success_attachments = $GLOBALS['deleted_attachments'];
|
||||
$delete_auth_projection = $GLOBALS['user_meta'][41];
|
||||
|
||||
$wpdb->delete_calls = [];
|
||||
$wpdb->event_delete_result = false;
|
||||
try {
|
||||
$probe->handle_delete_verify();
|
||||
} catch (HarnessRedirect $redirect) {
|
||||
parse_str((string) parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
|
||||
$delete_event_failure_redirect = $query;
|
||||
}
|
||||
$delete_event_failure_calls = $wpdb->delete_calls;
|
||||
$wpdb->event_delete_result = 1;
|
||||
|
||||
$wpdb->prepared = [];
|
||||
$wpdb->executed = [];
|
||||
$wpdb->delete_calls = [];
|
||||
$wpdb->application_delete_result = 0;
|
||||
$GLOBALS['deleted_attachments'] = [];
|
||||
$_POST = [
|
||||
'application_uuid' => 'delete-uuid-501',
|
||||
'filter_status' => 'pending',
|
||||
'keyword' => 'delete failure',
|
||||
'paged' => '1',
|
||||
'_wpnonce' => wp_create_nonce('aksrt_users_delete_verify_delete-uuid-501'),
|
||||
];
|
||||
try {
|
||||
$probe->handle_delete_verify();
|
||||
} catch (HarnessRedirect $redirect) {
|
||||
parse_str((string) parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
|
||||
$delete_failure_redirect = $query;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'html' => $html,
|
||||
'pending_empty' => $pending_empty,
|
||||
'search_empty' => $search_empty,
|
||||
'page_prepared' => $page_prepared,
|
||||
'page_executed' => $page_executed,
|
||||
'cache_calls' => $page_cache_calls,
|
||||
'meta_cache_calls' => $page_meta_cache_calls,
|
||||
'wrong_hook_assets' => $wrong_hook_assets,
|
||||
'auth_page_assets' => $auth_page_assets,
|
||||
'evidence_permissions' => $evidence_permissions,
|
||||
'delete_event_failure_redirect' => $delete_event_failure_redirect ?? [],
|
||||
'delete_event_failure_calls' => $delete_event_failure_calls,
|
||||
'review_call' => $review_call,
|
||||
'review_redirect' => $review_redirect ?? [],
|
||||
'review_nonce_calls' => $review_nonce_calls,
|
||||
'review_error_redirect' => $review_error_redirect ?? [],
|
||||
'review_error_call' => $review_error_call,
|
||||
'permission_denied' => $permission_denied,
|
||||
'bad_nonce_rejected' => $bad_nonce_rejected,
|
||||
'delete_success_redirect' => $delete_success_redirect ?? [],
|
||||
'delete_success_calls' => $delete_success_calls,
|
||||
'delete_success_attachments' => $delete_success_attachments,
|
||||
'delete_auth_projection' => $delete_auth_projection,
|
||||
'delete_user_meta_calls' => $GLOBALS['delete_user_meta_calls'],
|
||||
'delete_failure_redirect' => $delete_failure_redirect ?? [],
|
||||
'delete_failure_calls' => $wpdb->delete_calls,
|
||||
'delete_failure_attachments' => $GLOBALS['deleted_attachments'],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const runtimeSource = fs.readFileSync(path.join(root, 'assets', 'js', 'admin-user-auth.js'), 'utf8');
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, options = {}) {
|
||||
this.type = type;
|
||||
this.bubbles = options.bubbles !== false;
|
||||
this.key = options.key || '';
|
||||
this.clientX = options.clientX || 0;
|
||||
this.clientY = options.clientY || 0;
|
||||
this.defaultPrevented = false;
|
||||
this.cancelBubble = false;
|
||||
this.target = options.target || null;
|
||||
this.currentTarget = null;
|
||||
}
|
||||
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
stopPropagation() { this.cancelBubble = true; }
|
||||
}
|
||||
|
||||
class FakeClassList {
|
||||
constructor(element) { this.element = element; }
|
||||
values() { return new Set((this.element.className || '').split(/\s+/).filter(Boolean)); }
|
||||
add(...names) {
|
||||
const values = this.values();
|
||||
names.forEach((name) => values.add(name));
|
||||
this.element.className = [...values].join(' ');
|
||||
}
|
||||
remove(...names) {
|
||||
const values = this.values();
|
||||
names.forEach((name) => values.delete(name));
|
||||
this.element.className = [...values].join(' ');
|
||||
}
|
||||
contains(name) { return this.values().has(name); }
|
||||
toggle(name, force) {
|
||||
const present = this.contains(name);
|
||||
const next = force === undefined ? !present : Boolean(force);
|
||||
if (next) this.add(name); else this.remove(name);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesCompound(element, selector) {
|
||||
if (!element || element.nodeType !== 1) return false;
|
||||
const tag = selector.match(/^[a-zA-Z][\w-]*/);
|
||||
if (tag && element.tagName.toLowerCase() !== tag[0].toLowerCase()) return false;
|
||||
const classes = selector.match(/\.[\w-]+/g) || [];
|
||||
if (classes.some((name) => !element.classList.contains(name.slice(1)))) return false;
|
||||
const attributes = selector.match(/\[[^\]]+\]/g) || [];
|
||||
for (const token of attributes) {
|
||||
const match = token.slice(1, -1).match(/^([\w-]+)(?:\s*=\s*["']?([^"']*)["']?)?$/);
|
||||
if (!match || !element.hasAttribute(match[1])) return false;
|
||||
if (match[2] !== undefined && element.getAttribute(match[1]) !== match[2]) return false;
|
||||
}
|
||||
return Boolean(tag || classes.length || attributes.length);
|
||||
}
|
||||
|
||||
function matchesSelector(element, selector) {
|
||||
return selector.split(',').some((part) => {
|
||||
const chain = part.trim().split(/\s+/).filter(Boolean);
|
||||
if (!chain.length || !matchesCompound(element, chain[chain.length - 1])) return false;
|
||||
let ancestor = element.parentElement;
|
||||
for (let index = chain.length - 2; index >= 0; index -= 1) {
|
||||
while (ancestor && !matchesCompound(ancestor, chain[index])) ancestor = ancestor.parentElement;
|
||||
if (!ancestor) return false;
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = 'div', ownerDocument = null) {
|
||||
this.nodeType = tagName === '#document-fragment' ? 11 : 1;
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.ownerDocument = ownerDocument;
|
||||
this.parentNode = null;
|
||||
this._children = [];
|
||||
this._text = '';
|
||||
this.attributes = Object.create(null);
|
||||
this.dataset = Object.create(null);
|
||||
this.listeners = Object.create(null);
|
||||
this.className = '';
|
||||
this.classList = new FakeClassList(this);
|
||||
this.value = '';
|
||||
this.checked = false;
|
||||
this.disabled = false;
|
||||
this.hidden = false;
|
||||
this.required = false;
|
||||
this.type = '';
|
||||
this.name = '';
|
||||
this.open = false;
|
||||
this.reportValidityCalls = 0;
|
||||
this.customValidity = '';
|
||||
this.content = null;
|
||||
}
|
||||
|
||||
get children() { return this._children; }
|
||||
get parentElement() { return this.parentNode && this.parentNode.nodeType === 1 ? this.parentNode : null; }
|
||||
get isConnected() {
|
||||
let node = this;
|
||||
while (node) {
|
||||
if (this.ownerDocument && node === this.ownerDocument.documentElement) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
get textContent() { return this._text + this._children.map((child) => child.textContent).join(''); }
|
||||
set textContent(value) {
|
||||
this.replaceChildren();
|
||||
this._text = String(value);
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
if (child.nodeType === 11) {
|
||||
child._children.slice().forEach((item) => this.appendChild(item));
|
||||
child.replaceChildren();
|
||||
return child;
|
||||
}
|
||||
if (child.parentNode) child.parentNode.removeChild(child);
|
||||
this._children.push(child);
|
||||
child.parentNode = this;
|
||||
return child;
|
||||
}
|
||||
removeChild(child) {
|
||||
const index = this._children.indexOf(child);
|
||||
if (index !== -1) {
|
||||
this._children.splice(index, 1);
|
||||
child.parentNode = null;
|
||||
}
|
||||
return child;
|
||||
}
|
||||
replaceChildren(...children) {
|
||||
this._children.slice().forEach((child) => { child.parentNode = null; });
|
||||
this._children = [];
|
||||
this._text = '';
|
||||
children.forEach((child) => { if (child) this.appendChild(child); });
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
const stringValue = String(value);
|
||||
this.attributes[name] = stringValue;
|
||||
if (name === 'class') this.className = stringValue;
|
||||
if (name === 'value') this.value = stringValue;
|
||||
if (name === 'name') this.name = stringValue;
|
||||
if (name === 'type') this.type = stringValue;
|
||||
if (name === 'disabled') this.disabled = true;
|
||||
if (name === 'hidden') this.hidden = true;
|
||||
if (name === 'required') this.required = true;
|
||||
if (name.indexOf('data-') === 0) {
|
||||
const key = name.slice(5).replace(/-([a-z])/g, (_all, letter) => letter.toUpperCase());
|
||||
this.dataset[key] = stringValue;
|
||||
}
|
||||
}
|
||||
getAttribute(name) { return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : null; }
|
||||
hasAttribute(name) { return Object.prototype.hasOwnProperty.call(this.attributes, name); }
|
||||
removeAttribute(name) {
|
||||
delete this.attributes[name];
|
||||
if (name === 'disabled') this.disabled = false;
|
||||
if (name === 'hidden') this.hidden = false;
|
||||
if (name === 'required') this.required = false;
|
||||
}
|
||||
|
||||
addEventListener(type, callback) {
|
||||
if (!this.listeners[type]) this.listeners[type] = [];
|
||||
this.listeners[type].push(callback);
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
if (!event.target) event.target = this;
|
||||
let current = this;
|
||||
while (current) {
|
||||
event.currentTarget = current;
|
||||
(current.listeners[event.type] || []).slice().forEach((callback) => callback.call(current, event));
|
||||
if (!event.bubbles || event.cancelBubble) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return !event.defaultPrevented;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
const found = [];
|
||||
const visit = (node) => {
|
||||
node._children.forEach((child) => {
|
||||
if (matchesSelector(child, selector)) found.push(child);
|
||||
visit(child);
|
||||
});
|
||||
if (node.tagName === 'TEMPLATE' && node.content) visit(node.content);
|
||||
};
|
||||
visit(this);
|
||||
return found;
|
||||
}
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
focus() {
|
||||
if (this.ownerDocument) this.ownerDocument.activeElement = this;
|
||||
this.focused = true;
|
||||
}
|
||||
click() { return this.dispatchEvent(new FakeEvent('click')); }
|
||||
setCustomValidity(message) { this.customValidity = String(message); }
|
||||
reportValidity() {
|
||||
this.reportValidityCalls += 1;
|
||||
const controls = this.querySelectorAll('input, textarea, select');
|
||||
const enabled = controls.filter((field) => {
|
||||
let parent = field.parentElement;
|
||||
while (parent) {
|
||||
if (parent.tagName === 'FIELDSET' && parent.disabled) return false;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return !field.disabled;
|
||||
});
|
||||
const requiredRadioNames = new Set(enabled.filter((field) => field.type === 'radio' && field.required).map((field) => field.name));
|
||||
for (const name of requiredRadioNames) {
|
||||
if (!enabled.some((field) => field.type === 'radio' && field.name === name && field.checked)) return false;
|
||||
}
|
||||
for (const field of enabled) {
|
||||
if (field.customValidity) return false;
|
||||
if (field.required && field.type !== 'radio' && field.value === '') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
cloneNode(deep = false) {
|
||||
const clone = new FakeElement(this.tagName.toLowerCase(), this.ownerDocument);
|
||||
Object.entries(this.attributes).forEach(([name, value]) => clone.setAttribute(name, value));
|
||||
clone.value = this.value;
|
||||
clone.checked = this.checked;
|
||||
clone.disabled = this.disabled;
|
||||
clone.hidden = this.hidden;
|
||||
clone.required = this.required;
|
||||
clone.type = this.type;
|
||||
clone.name = this.name;
|
||||
clone.customValidity = this.customValidity;
|
||||
clone._text = this._text;
|
||||
clone.dataset = Object.assign(Object.create(null), this.dataset);
|
||||
if (this.content) clone.content = this.content.cloneNode(true);
|
||||
if (deep) this._children.forEach((child) => clone.appendChild(child.cloneNode(true)));
|
||||
return clone;
|
||||
}
|
||||
getBoundingClientRect() { return { left: 100, top: 100, right: 600, bottom: 700 }; }
|
||||
showModal() { this.open = true; }
|
||||
close() {
|
||||
if (!this.open) return;
|
||||
this.open = false;
|
||||
this.dispatchEvent(new FakeEvent('close', { bubbles: false }));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDocument {
|
||||
constructor() {
|
||||
this.documentElement = new FakeElement('html', this);
|
||||
this.body = new FakeElement('body', this);
|
||||
this.documentElement.appendChild(this.body);
|
||||
this.activeElement = null;
|
||||
}
|
||||
querySelector(selector) { return this.documentElement.querySelector(selector); }
|
||||
getElementById(id) {
|
||||
const visit = (node) => {
|
||||
if (node.getAttribute && node.getAttribute('id') === id) return node;
|
||||
for (const child of node.children || []) {
|
||||
const match = visit(child);
|
||||
if (match) return match;
|
||||
}
|
||||
if (node.tagName === 'TEMPLATE' && node.content) return visit(node.content);
|
||||
return null;
|
||||
};
|
||||
return visit(this.documentElement);
|
||||
}
|
||||
}
|
||||
|
||||
function makeElement(document, tag, attributes = {}, text = '') {
|
||||
const element = new FakeElement(tag, document);
|
||||
Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value));
|
||||
if (text !== '') element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
function addTemplate(rootElement, document, id, child) {
|
||||
const template = makeElement(document, 'template', { id });
|
||||
template.content = new FakeElement('#document-fragment', document);
|
||||
template.content.appendChild(child);
|
||||
rootElement.appendChild(template);
|
||||
return template;
|
||||
}
|
||||
|
||||
function makeCloseButton(document) {
|
||||
return makeElement(document, 'button', { type: 'button', 'data-aksrt-dialog-close': '' }, '关闭');
|
||||
}
|
||||
|
||||
function makeDetailFrame(document, text = '<img src=x onerror=alert(1)>') {
|
||||
const form = makeElement(document, 'form', { method: 'post', class: 'aksrt-verify-dialog__frame', 'data-aksrt-verify-review': '' });
|
||||
const head = makeElement(document, 'header', { class: 'aksrt-verify-dialog__head' });
|
||||
head.appendChild(makeElement(document, 'h2', { id: 'aksrt-verify-dialog-title' }, '认证资料审核'));
|
||||
head.appendChild(makeElement(document, 'p', { id: 'aksrt-verify-dialog-description' }, '核对申请人与所提交的材料。'));
|
||||
head.appendChild(makeCloseButton(document));
|
||||
form.appendChild(head);
|
||||
|
||||
const body = makeElement(document, 'div', { class: 'aksrt-verify-dialog__body' });
|
||||
body.appendChild(makeElement(document, 'div', { class: 'aksrt-verify-applicant' }, '申请人 17'));
|
||||
const meta = makeElement(document, 'dl', { class: 'aksrt-verify-meta' });
|
||||
meta.appendChild(makeElement(document, 'div', {}, '用户资料'));
|
||||
body.appendChild(meta);
|
||||
const section = makeElement(document, 'section', { class: 'aksrt-verify-section' });
|
||||
section.appendChild(makeElement(document, 'h3', {}, '申请说明'));
|
||||
section.appendChild(makeElement(document, 'div', { class: 'aksrt-verify-copy' }, text));
|
||||
body.appendChild(section);
|
||||
const evidence = makeElement(document, 'ul', { class: 'aksrt-verify-evidence' });
|
||||
const evidenceItem = makeElement(document, 'li');
|
||||
const evidenceLink = makeElement(document, 'a', { href: 'https://example.test/evidence.jpg' });
|
||||
evidenceLink.appendChild(makeElement(document, 'img', { src: 'https://example.test/thumb.jpg', alt: '证件材料' }));
|
||||
evidenceLink.appendChild(makeElement(document, 'span', {}, '证件材料'));
|
||||
evidenceItem.appendChild(evidenceLink);
|
||||
evidence.appendChild(evidenceItem);
|
||||
body.appendChild(evidence);
|
||||
|
||||
const review = makeElement(document, 'section', { class: 'aksrt-verify-review' });
|
||||
const decisions = makeElement(document, 'fieldset', { class: 'aksrt-verify-decisions' });
|
||||
decisions.appendChild(makeElement(document, 'legend', {}, '审核结果'));
|
||||
for (const [value, label] of [['verified', '通过'], ['rejected', '拒绝']]) {
|
||||
const choice = makeElement(document, 'label');
|
||||
const radio = makeElement(document, 'input', { type: 'radio', name: 'status', value, required: '' });
|
||||
choice.appendChild(radio);
|
||||
choice.appendChild(makeElement(document, 'span', {}, label));
|
||||
decisions.appendChild(choice);
|
||||
}
|
||||
review.appendChild(decisions);
|
||||
|
||||
const profile = makeElement(document, 'fieldset', { class: 'aksrt-verify-profile', 'data-aksrt-verify-profile': '', hidden: '', disabled: '' });
|
||||
const nameField = makeElement(document, 'label', { class: 'aksrt-native-field' });
|
||||
nameField.appendChild(makeElement(document, 'span', {}, '认证名称'));
|
||||
nameField.appendChild(makeElement(document, 'input', { type: 'text', name: 'name', maxlength: '64', required: '', value: 'Existing profile name', 'data-required-error': '请填写认证名称' }));
|
||||
profile.appendChild(nameField);
|
||||
const descriptionField = makeElement(document, 'label', { class: 'aksrt-native-field' });
|
||||
descriptionField.appendChild(makeElement(document, 'span', {}, '认证说明'));
|
||||
const description = makeElement(document, 'textarea', { name: 'description' });
|
||||
description.value = 'Existing profile description';
|
||||
descriptionField.appendChild(description);
|
||||
profile.appendChild(descriptionField);
|
||||
review.appendChild(profile);
|
||||
|
||||
const noteLabel = makeElement(document, 'label', { class: 'aksrt-native-field' });
|
||||
noteLabel.appendChild(makeElement(document, 'span', { 'data-aksrt-verify-note-label': '', 'data-optional': '处理留言(选填)', 'data-required': '拒绝原因(必填)' }, '处理留言(选填)'));
|
||||
noteLabel.appendChild(makeElement(document, 'textarea', { name: 'note', 'data-required-error': '请填写拒绝原因' }));
|
||||
review.appendChild(noteLabel);
|
||||
body.appendChild(review);
|
||||
form.appendChild(body);
|
||||
|
||||
const foot = makeElement(document, 'footer', { class: 'aksrt-verify-dialog__foot' });
|
||||
foot.appendChild(makeElement(document, 'button', { type: 'submit', class: 'button button-primary', 'data-aksrt-verify-submit': '', 'data-idle-label': '请选择审核结果', 'data-approve-label': '确认通过', 'data-reject-label': '确认拒绝', 'data-busy-label': '正在提交…', disabled: '' }, '请选择审核结果'));
|
||||
form.appendChild(foot);
|
||||
|
||||
form.appendChild(makeElement(document, 'input', { type: 'hidden', name: '_wpnonce', value: 'nonce-17' }));
|
||||
form.appendChild(makeElement(document, 'input', { type: 'hidden', name: 'application_uuid', value: 'uuid-17' }));
|
||||
form.appendChild(makeElement(document, 'input', { type: 'hidden', name: 'user_id', value: '17' }));
|
||||
return form;
|
||||
}
|
||||
|
||||
function makeDeleteFrame(document) {
|
||||
const form = makeElement(document, 'form', { method: 'post', class: 'aksrt-verify-dialog__frame', 'data-aksrt-verify-delete-form': '' });
|
||||
const head = makeElement(document, 'header', { class: 'aksrt-verify-dialog__head' });
|
||||
head.appendChild(makeElement(document, 'h2', { id: 'aksrt-verify-dialog-title' }, '删除认证记录'));
|
||||
head.appendChild(makeElement(document, 'p', { id: 'aksrt-verify-dialog-description' }, '请确认后再继续。'));
|
||||
head.appendChild(makeCloseButton(document));
|
||||
form.appendChild(head);
|
||||
const body = makeElement(document, 'div', { class: 'aksrt-verify-dialog__body' });
|
||||
body.appendChild(makeElement(document, 'div', { class: 'aksrt-native-callout is-danger' }, '删除不可撤销,只删除记录、材料与事件,不撤销已完成的认证。'));
|
||||
form.appendChild(body);
|
||||
const foot = makeElement(document, 'footer', { class: 'aksrt-verify-dialog__foot' });
|
||||
foot.appendChild(makeElement(document, 'button', { type: 'submit', 'data-aksrt-verify-submit': '', 'data-idle-label': '确认删除', 'data-busy-label': '正在提交…' }, '确认删除'));
|
||||
form.appendChild(foot);
|
||||
form.appendChild(makeElement(document, 'input', { type: 'hidden', name: '_wpnonce', value: 'delete-nonce-17' }));
|
||||
form.appendChild(makeElement(document, 'input', { type: 'hidden', name: 'application_uuid', value: 'delete-uuid-17' }));
|
||||
return form;
|
||||
}
|
||||
|
||||
function makeReadonlyFrame(document) {
|
||||
const frame = makeElement(document, 'div', { class: 'aksrt-verify-dialog__frame' });
|
||||
const head = makeElement(document, 'header', { class: 'aksrt-verify-dialog__head' });
|
||||
head.appendChild(makeElement(document, 'h2', { id: 'aksrt-verify-dialog-title' }, '认证记录详情'));
|
||||
head.appendChild(makeElement(document, 'p', { id: 'aksrt-verify-dialog-description' }, '此记录仅供查看。'));
|
||||
head.appendChild(makeCloseButton(document));
|
||||
frame.appendChild(head);
|
||||
frame.appendChild(makeElement(document, 'div', { class: 'aksrt-verify-dialog__body' }, '只读内容'));
|
||||
frame.appendChild(makeElement(document, 'footer', { class: 'aksrt-verify-dialog__foot' }));
|
||||
return frame;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const document = new FakeDocument();
|
||||
const rootElement = makeElement(document, 'main', { class: 'aksrt-verify-page' });
|
||||
const view17 = makeElement(document, 'button', { type: 'button', 'data-aksrt-verify-view': '17', 'aria-controls': 'aksrt-verify-dialog' }, '查看审核');
|
||||
const view18 = makeElement(document, 'button', { type: 'button', 'data-aksrt-verify-view': '18', 'aria-controls': 'aksrt-verify-dialog' }, '查看只读记录');
|
||||
const delete17 = makeElement(document, 'button', { type: 'button', 'data-aksrt-verify-delete': '17', 'aria-controls': 'aksrt-verify-dialog' }, '删除');
|
||||
rootElement.appendChild(view17);
|
||||
rootElement.appendChild(view18);
|
||||
rootElement.appendChild(delete17);
|
||||
const detailTemplate = addTemplate(rootElement, document, 'aksrt-verify-detail-17', makeDetailFrame(document));
|
||||
const readonlyTemplate = addTemplate(rootElement, document, 'aksrt-verify-detail-18', makeReadonlyFrame(document));
|
||||
const deleteTemplate = addTemplate(rootElement, document, 'aksrt-verify-delete-17', makeDeleteFrame(document));
|
||||
const dialog = makeElement(document, 'dialog', {
|
||||
id: 'aksrt-verify-dialog',
|
||||
class: 'aksrt-verify-dialog',
|
||||
'aria-labelledby': 'aksrt-verify-dialog-title',
|
||||
'aria-describedby': 'aksrt-verify-dialog-description',
|
||||
});
|
||||
document.body.appendChild(rootElement);
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const windowListeners = Object.create(null);
|
||||
const window = {
|
||||
addEventListener(type, callback) {
|
||||
if (!windowListeners[type]) windowListeners[type] = [];
|
||||
windowListeners[type].push(callback);
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
(windowListeners[event.type] || []).forEach((callback) => callback(event));
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(runtimeSource, { document, window });
|
||||
return { document, root: rootElement, dialog, view17, view18, delete17, detailTemplate, readonlyTemplate, deleteTemplate, window };
|
||||
}
|
||||
|
||||
function open(button) { button.click(); }
|
||||
function submit(form) {
|
||||
const event = new FakeEvent('submit');
|
||||
form.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
function fire(element, type, options = {}) { return element.dispatchEvent(new FakeEvent(type, options)); }
|
||||
function reviewParts(form) {
|
||||
return {
|
||||
radios: form.querySelectorAll('input[name="status"]'),
|
||||
profile: form.querySelector('[data-aksrt-verify-profile]'),
|
||||
name: form.querySelector('input[name="name"]'),
|
||||
description: form.querySelector('textarea[name="description"]'),
|
||||
note: form.querySelector('textarea[name="note"]'),
|
||||
noteLabel: form.querySelector('[data-aksrt-verify-note-label]'),
|
||||
submit: form.querySelector('[data-aksrt-verify-submit]'),
|
||||
};
|
||||
}
|
||||
|
||||
test('details and deletion clone only their own inert templates; no markup parsing or stale review state', () => {
|
||||
const app = setup();
|
||||
assert.equal(app.dialog.children.length, 0, 'the shared dialog starts empty');
|
||||
|
||||
const sourceForm = app.detailTemplate.content.querySelector('form[data-aksrt-verify-review]');
|
||||
open(app.view17);
|
||||
const firstForm = app.dialog.querySelector('form[data-aksrt-verify-review]');
|
||||
assert.ok(firstForm);
|
||||
assert.notEqual(firstForm, sourceForm, 'dialog content is a deep clone, not the inert template node');
|
||||
assert.equal(app.dialog.querySelectorAll('script').length, 0);
|
||||
assert.match(app.dialog.querySelector('.aksrt-verify-copy').textContent, /<img src=x onerror=alert\(1\)>/);
|
||||
assert.equal(app.dialog.querySelector('.aksrt-verify-copy').querySelector('img'), null, 'untrusted copy stays text');
|
||||
|
||||
const parts = reviewParts(firstForm);
|
||||
assert.equal(parts.name.value, 'Existing profile name', 'the clone keeps server-prefilled profile data');
|
||||
assert.equal(parts.description.value, 'Existing profile description');
|
||||
parts.radios[1].checked = true;
|
||||
parts.note.value = '已核对';
|
||||
fire(parts.radios[1], 'change');
|
||||
assert.equal(parts.submit.textContent, '确认拒绝');
|
||||
assert.equal(sourceForm.querySelector('textarea[name="note"]').value, '', 'editing the clone does not mutate the template');
|
||||
|
||||
app.dialog.querySelector('[data-aksrt-dialog-close]').click();
|
||||
assert.equal(app.dialog.children.length, 0, 'closing removes every cloned node');
|
||||
assert.equal(app.document.activeElement, app.view17, 'close restores focus to the opener');
|
||||
open(app.view17);
|
||||
const secondForm = app.dialog.querySelector('form[data-aksrt-verify-review]');
|
||||
assert.notEqual(secondForm, firstForm, 'each open builds a fresh clone');
|
||||
assert.equal(reviewParts(secondForm).radios.some((radio) => radio.checked), false);
|
||||
assert.equal(reviewParts(secondForm).note.value, '', 'the previous note is not retained');
|
||||
assert.equal(reviewParts(secondForm).name.value, 'Existing profile name', 'fresh clones preserve server-provided profile defaults');
|
||||
|
||||
app.dialog.querySelector('[data-aksrt-dialog-close]').click();
|
||||
open(app.delete17);
|
||||
const deleteForm = app.dialog.querySelector('form[data-aksrt-verify-delete-form]');
|
||||
const sourceDeleteForm = app.deleteTemplate.content.querySelector('form[data-aksrt-verify-delete-form]');
|
||||
assert.ok(deleteForm);
|
||||
assert.notEqual(deleteForm, sourceDeleteForm, 'deletion actions also clone their own inert template');
|
||||
assert.equal(app.dialog.querySelector('form[data-aksrt-verify-review]'), null);
|
||||
assert.equal(app.dialog.querySelector('.aksrt-native-callout.is-danger').textContent.includes('不撤销已完成的认证'), true);
|
||||
assert.equal(app.dialog.querySelector('[data-aksrt-verify-submit]').textContent, '确认删除');
|
||||
assert.equal(app.dialog.querySelectorAll('input[name="status"]').length, 0);
|
||||
});
|
||||
|
||||
test('cancel, Escape, and backdrop close the dialog; backdrop must begin and end outside', () => {
|
||||
const app = setup();
|
||||
open(app.view17);
|
||||
const cancel = new FakeEvent('cancel', { bubbles: false });
|
||||
app.dialog.dispatchEvent(cancel);
|
||||
assert.equal(cancel.defaultPrevented, true, 'cancel is handled through the shared close path');
|
||||
assert.equal(app.dialog.open, false);
|
||||
assert.equal(app.document.activeElement, app.view17);
|
||||
|
||||
open(app.view17);
|
||||
fire(app.dialog, 'keydown', { key: 'Escape' });
|
||||
assert.equal(app.dialog.open, true, 'Escape is completed by the native cancel event');
|
||||
fire(app.dialog, 'pointerdown', { clientX: 120, clientY: 130 });
|
||||
fire(app.dialog, 'click', { clientX: 700, clientY: 800 });
|
||||
assert.equal(app.dialog.open, true, 'a gesture that began inside cannot dismiss the dialog');
|
||||
fire(app.dialog, 'pointerdown', { clientX: 700, clientY: 800 });
|
||||
fire(app.dialog, 'click', { clientX: 120, clientY: 130 });
|
||||
assert.equal(app.dialog.open, true, 'a gesture that ended inside cannot dismiss the dialog');
|
||||
fire(app.dialog, 'pointerdown', { clientX: 700, clientY: 800 });
|
||||
fire(app.dialog, 'click', { clientX: 700, clientY: 800 });
|
||||
assert.equal(app.dialog.open, false, 'a full outside pointer gesture closes the dialog');
|
||||
assert.equal(app.dialog.children.length, 0);
|
||||
assert.equal(app.document.activeElement, app.view17);
|
||||
});
|
||||
|
||||
test('review starts unselected and cannot submit before a decision', () => {
|
||||
const app = setup();
|
||||
open(app.view17);
|
||||
const form = app.dialog.querySelector('form[data-aksrt-verify-review]');
|
||||
const parts = reviewParts(form);
|
||||
assert.equal(parts.radios.some((radio) => radio.checked), false);
|
||||
assert.equal(parts.submit.disabled, true);
|
||||
assert.equal(parts.submit.textContent, '请选择审核结果');
|
||||
assert.equal(parts.profile.hidden, true);
|
||||
assert.equal(parts.profile.disabled, true);
|
||||
assert.equal(parts.note.required, false);
|
||||
|
||||
const event = submit(form);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.equal(form.reportValidityCalls, 1, 'native validity is reported even when no decision is selected');
|
||||
assert.notEqual(form.dataset.submitting, '1');
|
||||
});
|
||||
|
||||
test('approve reveals and enables profile fields; trimmed name validation and successful hidden fields are preserved', () => {
|
||||
const app = setup();
|
||||
open(app.view17);
|
||||
const form = app.dialog.querySelector('form[data-aksrt-verify-review]');
|
||||
const parts = reviewParts(form);
|
||||
parts.radios[0].checked = true;
|
||||
fire(parts.radios[0], 'change');
|
||||
assert.equal(parts.profile.hidden, false);
|
||||
assert.equal(parts.profile.disabled, false);
|
||||
assert.equal(parts.note.required, false);
|
||||
assert.equal(parts.noteLabel.textContent, '处理留言(选填)');
|
||||
assert.equal(parts.submit.disabled, false);
|
||||
assert.equal(parts.submit.textContent, '确认通过');
|
||||
|
||||
parts.name.value = ' ';
|
||||
fire(parts.name, 'input');
|
||||
const blankName = submit(form);
|
||||
assert.equal(blankName.defaultPrevented, true, 'whitespace-only names fail native reportValidity');
|
||||
assert.equal(parts.name.customValidity, '请填写认证名称');
|
||||
assert.equal(form.reportValidityCalls, 1);
|
||||
|
||||
parts.name.value = '认证名称';
|
||||
parts.description.value = '认证说明';
|
||||
fire(parts.name, 'input');
|
||||
const accepted = submit(form);
|
||||
assert.equal(accepted.defaultPrevented, false, 'valid review submits through the browser form navigation');
|
||||
assert.equal(form.dataset.submitting, '1');
|
||||
assert.equal(parts.submit.disabled, true);
|
||||
assert.equal(parts.submit.textContent, '正在提交…');
|
||||
assert.equal(parts.name.disabled, false, 'successful user fields are not disabled while submitting');
|
||||
assert.equal(parts.description.disabled, false);
|
||||
assert.equal(parts.note.disabled, false);
|
||||
assert.equal(form.querySelector('[name="_wpnonce"]').value, 'nonce-17');
|
||||
assert.equal(form.querySelector('[name="_wpnonce"]').disabled, false);
|
||||
assert.equal(form.querySelector('[name="application_uuid"]').value, 'uuid-17');
|
||||
assert.equal(form.querySelector('[name="application_uuid"]').disabled, false);
|
||||
assert.equal(form.querySelector('[name="user_id"]').disabled, false);
|
||||
|
||||
parts.radios[0].checked = false;
|
||||
parts.radios[1].checked = true;
|
||||
fire(parts.radios[1], 'change');
|
||||
parts.note.value = '提交中不应切换审核状态';
|
||||
fire(parts.note, 'input');
|
||||
assert.equal(parts.submit.disabled, true, 'change/input events cannot re-enable actions after submission starts');
|
||||
assert.equal(parts.submit.textContent, '正在提交…', 'change/input events cannot overwrite the busy label');
|
||||
assert.equal(parts.profile.hidden, false, 'a busy review does not resync profile visibility');
|
||||
assert.equal(parts.note.required, false, 'a busy review does not resync required fields');
|
||||
assert.equal(parts.noteLabel.textContent, '处理留言(选填)');
|
||||
|
||||
const repeated = submit(form);
|
||||
assert.equal(repeated.defaultPrevented, true, 'second submission is stopped');
|
||||
assert.equal(form.reportValidityCalls, 2, 'duplicate submits do not run additional validation or navigation');
|
||||
});
|
||||
|
||||
test('reject hides profile fields, requires a trimmed reason, and applies danger action state', () => {
|
||||
const app = setup();
|
||||
open(app.view17);
|
||||
const form = app.dialog.querySelector('form[data-aksrt-verify-review]');
|
||||
const parts = reviewParts(form);
|
||||
parts.radios[1].checked = true;
|
||||
fire(parts.radios[1], 'change');
|
||||
assert.equal(parts.profile.hidden, true);
|
||||
assert.equal(parts.profile.disabled, true);
|
||||
assert.equal(parts.note.required, true);
|
||||
assert.equal(parts.noteLabel.textContent, '拒绝原因(必填)');
|
||||
assert.equal(parts.submit.disabled, false);
|
||||
assert.equal(parts.submit.textContent, '确认拒绝');
|
||||
assert.equal(parts.submit.classList.contains('is-danger'), true);
|
||||
|
||||
parts.note.value = ' \n ';
|
||||
fire(parts.note, 'input');
|
||||
const blankReason = submit(form);
|
||||
assert.equal(blankReason.defaultPrevented, true);
|
||||
assert.equal(parts.note.customValidity, '请填写拒绝原因');
|
||||
assert.equal(form.reportValidityCalls, 1);
|
||||
|
||||
parts.note.value = '材料不完整';
|
||||
fire(parts.note, 'input');
|
||||
const rejected = submit(form);
|
||||
assert.equal(rejected.defaultPrevented, false);
|
||||
assert.equal(parts.note.disabled, false);
|
||||
assert.equal(form.querySelector('[name="_wpnonce"]').value, 'nonce-17');
|
||||
assert.equal(form.querySelector('[name="application_uuid"]').value, 'uuid-17');
|
||||
});
|
||||
|
||||
test('delete submits without confirm, blocks repeat/close while busy, and pageshow restores actions and scroll lock', () => {
|
||||
const app = setup();
|
||||
open(app.delete17);
|
||||
const form = app.dialog.querySelector('form[data-aksrt-verify-delete-form]');
|
||||
const submitButton = form.querySelector('[data-aksrt-verify-submit]');
|
||||
const closeButton = form.querySelector('[data-aksrt-dialog-close]');
|
||||
const first = submit(form);
|
||||
assert.equal(first.defaultPrevented, false);
|
||||
assert.equal(form.dataset.submitting, '1');
|
||||
assert.equal(submitButton.disabled, true);
|
||||
assert.equal(closeButton.disabled, true);
|
||||
assert.equal(submitButton.textContent, '正在提交…');
|
||||
assert.equal(form.querySelector('[name="_wpnonce"]').value, 'delete-nonce-17');
|
||||
assert.equal(form.querySelector('[name="application_uuid"]').value, 'delete-uuid-17');
|
||||
assert.equal(form.querySelector('[name="_wpnonce"]').disabled, false);
|
||||
assert.equal(form.querySelector('[name="application_uuid"]').disabled, false);
|
||||
assert.equal(app.document.documentElement.classList.contains('aksrt-verify-modal-open'), true);
|
||||
|
||||
assert.equal(submit(form).defaultPrevented, true, 'duplicate delete submission is blocked');
|
||||
const escape = new FakeEvent('keydown', { key: 'Escape', bubbles: false });
|
||||
app.dialog.dispatchEvent(escape);
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
const cancel = new FakeEvent('cancel', { bubbles: false });
|
||||
app.dialog.dispatchEvent(cancel);
|
||||
assert.equal(cancel.defaultPrevented, true);
|
||||
assert.equal(app.dialog.open, true, 'busy submissions cannot close through keyboard');
|
||||
closeButton.click();
|
||||
assert.equal(app.dialog.open, true, 'busy submissions cannot close through the close action');
|
||||
|
||||
fire(app.dialog, 'pointerdown', { clientX: 700, clientY: 800 });
|
||||
fire(app.dialog, 'click', { clientX: 700, clientY: 800 });
|
||||
assert.equal(app.dialog.open, true, 'busy submissions cannot close through the backdrop');
|
||||
|
||||
app.window.dispatchEvent(new FakeEvent('pageshow'));
|
||||
assert.equal(form.dataset.submitting, undefined);
|
||||
assert.equal(submitButton.disabled, false);
|
||||
assert.equal(closeButton.disabled, false);
|
||||
assert.equal(submitButton.textContent, '确认删除');
|
||||
assert.equal(app.dialog.open, true);
|
||||
assert.equal(app.document.documentElement.classList.contains('aksrt-verify-modal-open'), true, 'an open dialog retains the scroll lock');
|
||||
|
||||
app.dialog.querySelector('[data-aksrt-dialog-close]').click();
|
||||
assert.equal(app.document.documentElement.classList.contains('aksrt-verify-modal-open'), false);
|
||||
app.window.dispatchEvent(new FakeEvent('pageshow'));
|
||||
assert.equal(app.document.documentElement.classList.contains('aksrt-verify-modal-open'), false, 'closed pageshow state is unlocked');
|
||||
});
|
||||
|
||||
test('read-only detail has no review submit behavior', () => {
|
||||
const app = setup();
|
||||
open(app.view18);
|
||||
assert.ok(app.dialog.querySelector('.aksrt-verify-dialog__frame'));
|
||||
assert.equal(app.dialog.querySelector('form'), null);
|
||||
assert.equal(app.dialog.querySelector('[data-aksrt-verify-submit]'), null);
|
||||
assert.match(app.dialog.textContent, /此记录仅供查看/);
|
||||
app.dialog.querySelector('[data-aksrt-dialog-close]').click();
|
||||
assert.equal(app.dialog.children.length, 0);
|
||||
});
|
||||
Reference in new issue
Block a user