From bc26d6260262f84327f332e414a3f382c4348b13 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 30 Oct 2026 20:05:21 +0800 Subject: [PATCH] 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 --- assets/css/admin/user-auth.css | 212 ++++++ assets/js/admin-user-auth.js | 255 +++++++ src/Admin/Native/UsersMenus.php | 1 + src/Admin/Native/UsersMenusVerify.php | 275 ++++--- src/Admin/Native/UsersMenusVerifyDialogs.php | 124 ++++ src/Identity/EvidenceService.php | 8 +- .../user-auth-admin-contract.test.cjs | 271 +++++++ .../widget-system/user-auth-admin-harness.php | 518 ++++++++++++++ .../user-auth-admin-runtime.test.cjs | 674 ++++++++++++++++++ 9 files changed, 2222 insertions(+), 116 deletions(-) create mode 100644 assets/css/admin/user-auth.css create mode 100644 assets/js/admin-user-auth.js create mode 100644 src/Admin/Native/UsersMenusVerifyDialogs.php create mode 100644 tests/widget-system/user-auth-admin-contract.test.cjs create mode 100644 tests/widget-system/user-auth-admin-harness.php create mode 100644 tests/widget-system/user-auth-admin-runtime.test.cjs diff --git a/assets/css/admin/user-auth.css b/assets/css/admin/user-auth.css new file mode 100644 index 00000000..7c97af23 --- /dev/null +++ b/assets/css/admin/user-auth.css @@ -0,0 +1,212 @@ +/* 认证审核原生后台页。卡片、表单与颜色令牌继承 native-ui.css。 */ +.aksrt-verify-page [hidden], +.aksrt-verify-dialog [hidden] { display: none !important; } + +.aksrt-verify-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 18px; + padding: 14px 20px; + border-bottom: 1px solid var(--an-border-soft); +} +.aksrt-verify-toolbar__views { + display: flex; + flex: 1 1 620px; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 18px; +} +.aksrt-verify-toolbar .subsubsub { margin: 0; padding: 0; box-shadow: none; } +.aksrt-verify-toolbar .aksrt-native-filters { flex: 0 1 auto; margin: 0; } +.aksrt-verify-toolbar .aksrt-native-filters input[type="search"] { min-width: 200px; } + +.aksrt-verify-page .aksrt-verify-table { min-width: 1040px; table-layout: fixed; } +.aksrt-verify-table .column-user { width: 20%; } +.aksrt-verify-table .column-profile { width: 24%; } +.aksrt-verify-table .column-evidence { width: 10%; } +.aksrt-verify-table .column-status { width: 10%; } +.aksrt-verify-table .column-time { width: 18%; } +.aksrt-verify-table .column-actions { width: 18%; } +.aksrt-verify-page .aksrt-verify-table td { vertical-align: middle; overflow-wrap: anywhere; } +.aksrt-verify-summary { + display: -webkit-box; + max-height: 3.1em; + margin-top: 4px; + color: var(--an-text-2); + font-size: 12px; + line-height: 1.55; + overflow: hidden; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.aksrt-verify-sub { margin-top: 4px; color: var(--an-text-2); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; } +.aksrt-verify-row-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; } +.aksrt-verify-row-actions a, +.aksrt-verify-row-actions button { white-space: nowrap; } +.aksrt-verify-row-actions .button-link-delete { color: var(--an-danger); } +.aksrt-verify-page :is(a, button):focus-visible, +.aksrt-verify-dialog :is(a, button, input, textarea):focus-visible { + outline: 2px solid var(--an-primary); + outline-offset: 2px; +} + +html.aksrt-verify-modal-open { overflow: hidden; } +.aksrt-verify-dialog { + width: min(760px, calc(100vw - 32px)); + max-width: none; + max-height: calc(100vh - 40px); + max-height: calc(100dvh - 40px); + margin: auto; + padding: 0; + overflow: hidden; + color: var(--an-text-2); + background: var(--an-card); + border: 1px solid var(--an-border-soft); + border-radius: var(--an-radius); + box-shadow: var(--an-shadow-hover); +} +.aksrt-verify-dialog::backdrop { background: rgba(15, 23, 42, .45); } +.aksrt-verify-dialog__frame { display: flex; flex-direction: column; max-height: inherit; min-height: 0; margin: 0; } +.aksrt-verify-dialog__head { + display: flex; + flex: none; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 19px 24px 17px; + border-bottom: 1px solid var(--an-border-soft); +} +.aksrt-verify-dialog .aksrt-verify-dialog__head h2 { + margin: 0; + padding: 0; + color: var(--an-text); + font-size: 18px; + font-weight: 600; + line-height: 1.45; +} +.aksrt-verify-dialog__head p { margin: 5px 0 0; color: var(--an-text-2); font-size: 13px; line-height: 1.6; } +.aksrt-verify-dialog__head [data-aksrt-dialog-close] { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + color: var(--an-text-2); + background: transparent; + border: 0; + border-radius: var(--an-radius-sm); + cursor: pointer; +} +.aksrt-verify-dialog__head [data-aksrt-dialog-close]:hover { color: var(--an-text); background: var(--an-bg-soft); } +.aksrt-verify-dialog__body { min-height: 0; padding: 20px 24px; overflow-y: auto; overscroll-behavior: contain; } +.aksrt-verify-dialog__foot { + display: flex; + flex: none; + justify-content: flex-end; + gap: 8px; + padding: 14px 24px; + background: var(--an-card); + border-top: 1px solid var(--an-border-soft); +} +.aksrt-verify-dialog__foot .button { min-width: 116px; } +.aksrt-verify-dialog__foot .button.is-danger { color: var(--an-card); background: var(--an-danger); border-color: var(--an-danger); } +.aksrt-verify-dialog__foot .button.is-danger:hover { background: var(--an-danger); border-color: var(--an-danger); } +.aksrt-verify-dialog__foot .button:disabled { cursor: not-allowed; } + +.aksrt-verify-applicant { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; min-width: 0; margin: 0 0 18px; color: var(--an-text); font-size: 17px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; } +.aksrt-verify-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + margin: 0; + padding: 14px 16px; + background: var(--an-bg-soft); + border-radius: var(--an-radius-sm); +} +.aksrt-verify-meta > div { min-width: 0; } +.aksrt-verify-meta dt { color: var(--an-text-2); font-size: 12px; line-height: 1.5; } +.aksrt-verify-meta dd { margin: 3px 0 0; color: var(--an-text); overflow-wrap: anywhere; } +.aksrt-verify-section { margin-top: 20px; } +.aksrt-verify-dialog .aksrt-verify-section h3, +.aksrt-verify-review > h3 { + margin: 0 0 9px; + padding: 0; + color: var(--an-text); + font-size: 14px; + font-weight: 600; + line-height: 1.45; +} +.aksrt-verify-copy { color: var(--an-text-2); line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; } +.aksrt-verify-evidence { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; } +.aksrt-verify-evidence li { min-width: 0; } +.aksrt-verify-evidence a { + display: flex; + align-items: center; + gap: 9px; + min-height: 52px; + padding: 7px 9px; + color: var(--an-text-2); + background: var(--an-bg-soft); + border: 1px solid var(--an-border-soft); + border-radius: var(--an-radius-sm); + text-decoration: none; +} +.aksrt-verify-evidence a:hover { color: var(--an-primary); border-color: var(--an-primary); } +.aksrt-verify-evidence img { flex: none; width: 38px; height: 38px; object-fit: cover; border-radius: 5px; } +.aksrt-verify-evidence span { min-width: 0; overflow-wrap: anywhere; } +.aksrt-verify-evidence-empty { margin: 0; color: var(--an-text-2); } + +.aksrt-verify-review { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--an-border-soft); } +.aksrt-verify-decisions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 16px; padding: 0; border: 0; } +.aksrt-verify-decisions > legend { width: 100%; margin-bottom: 8px; padding: 0; color: var(--an-text); font-weight: 600; } +.aksrt-verify-decisions label { position: relative; flex: 1 1 180px; cursor: pointer; } +.aksrt-verify-decisions input[type="radio"] { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); } +.aksrt-verify-decisions label > span { + display: flex; + align-items: center; + min-height: 42px; + padding: 9px 12px; + color: var(--an-text-2); + background: var(--an-card); + border: 1px solid var(--an-border); + border-radius: var(--an-radius-sm); +} +.aksrt-verify-decisions label:hover > span { border-color: var(--an-primary); } +.aksrt-verify-decisions input:checked + span { color: var(--an-primary); background: var(--an-primary-soft); border-color: var(--an-primary); font-weight: 600; } +.aksrt-verify-decisions input:focus-visible + span { outline: 2px solid var(--an-primary); outline-offset: 2px; } +.aksrt-verify-profile { + min-width: 0; + margin: 0 0 16px; + padding: 14px; + border: 1px solid var(--an-border); + border-radius: var(--an-radius-sm); +} +.aksrt-verify-profile > legend { padding: 0 5px; color: var(--an-text); font-weight: 600; } +.aksrt-verify-review .aksrt-native-field { margin-top: 13px; } +.aksrt-verify-review .aksrt-native-field > input, +.aksrt-verify-review .aksrt-native-field > textarea { width: 100%; max-width: none; } +.aksrt-verify-review textarea { resize: vertical; } + +@media (max-width: 782px) { + .aksrt-verify-toolbar { padding: 12px 14px; } + .aksrt-verify-toolbar__views { flex: 1 1 100%; justify-content: flex-start; } + .aksrt-verify-toolbar .aksrt-native-filters { width: 100%; } + .aksrt-verify-toolbar .aksrt-native-filters input[type="search"] { min-width: 0; flex: 1 1 220px; } +} +@media (max-width: 600px) { + .aksrt-verify-dialog { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); } + .aksrt-verify-dialog__head, + .aksrt-verify-dialog__body, + .aksrt-verify-dialog__foot { padding-left: 16px; padding-right: 16px; } + .aksrt-verify-meta { grid-template-columns: 1fr; gap: 10px; } + .aksrt-verify-evidence { grid-template-columns: 1fr; } + .aksrt-verify-decisions { flex-direction: column; } + .aksrt-verify-decisions label { flex-basis: auto; } + .aksrt-verify-dialog__foot .button { flex: 1; } +} diff --git a/assets/js/admin-user-auth.js b/assets/js/admin-user-auth.js new file mode 100644 index 00000000..0104c101 --- /dev/null +++ b/assets/js/admin-user-auth.js @@ -0,0 +1,255 @@ +/** 认证记录审核详情与删除确认。仅在认证 native 后台页入队。 */ +(function () { + 'use strict'; + + var root = document.querySelector('.aksrt-verify-page'); + var dialog = document.getElementById('aksrt-verify-dialog'); + if (!root || !dialog || typeof dialog.showModal !== 'function') return; + + var opener = null; + var activeForm = null; + var submitting = false; + var pointerStartedOutside = false; + + function prevent(event) { + if (event && typeof event.preventDefault === 'function') event.preventDefault(); + } + + function valueOf(element, name, fallback) { + var value = element ? element.getAttribute(name) : null; + return value === null ? (fallback || '') : value; + } + + function lockScroll() { + document.documentElement.classList.toggle('aksrt-verify-modal-open', !!dialog.open); + } + + function closestAttribute(target, attribute, boundary) { + var node = target; + while (node) { + if (node.nodeType === 1 && node.hasAttribute(attribute)) return node; + if (node === boundary) break; + node = node.parentElement; + } + return null; + } + + function isOutside(event) { + var rect = dialog.getBoundingClientRect(); + return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom; + } + + function selectedStatus(form) { + var radios = form.querySelectorAll('input[name="status"]'); + for (var index = 0; index < radios.length; index += 1) { + if (radios[index].checked) return radios[index].value; + } + return ''; + } + + function getProfileName(profile) { + return profile ? profile.querySelector('input[name="name"]') : null; + } + + function setTrimValidity(field, active) { + if (!field || typeof field.setCustomValidity !== 'function') return; + if (!active || String(field.value || '').trim() !== '') { + field.setCustomValidity(''); + return; + } + field.setCustomValidity(valueOf(field, 'data-required-error', '必填项不能为空')); + } + + function syncReview(form) { + var decision = selectedStatus(form); + var profile = form.querySelector('[data-aksrt-verify-profile]'); + var name = getProfileName(profile); + var note = form.querySelector('textarea[name="note"]'); + var noteLabel = form.querySelector('[data-aksrt-verify-note-label]'); + var submit = form.querySelector('[data-aksrt-verify-submit]'); + var rejected = decision === 'rejected'; + var approved = decision === 'verified'; + + if (profile) { + profile.hidden = !approved; + profile.disabled = !approved; + } + if (note) { + note.required = rejected; + setTrimValidity(note, rejected); + } + setTrimValidity(name, approved); + if (noteLabel) { + noteLabel.textContent = valueOf(noteLabel, rejected ? 'data-required' : 'data-optional'); + } + if (submit) { + var idleLabel = valueOf(submit, 'data-idle-label'); + submit.disabled = !decision; + submit.textContent = approved + ? valueOf(submit, 'data-approve-label', idleLabel) + : rejected + ? valueOf(submit, 'data-reject-label', idleLabel) + : idleLabel; + submit.classList.toggle('is-danger', rejected); + } + } + + function resetReview(form) { + var radios = form.querySelectorAll('input[name="status"]'); + radios.forEach(function (radio) { radio.checked = false; }); + + var note = form.querySelector('textarea[name="note"]'); + if (note) note.value = ''; + syncReview(form); + } + + function actionButtons() { + var buttons = []; + var submitButtons = dialog.querySelectorAll('[data-aksrt-verify-submit]'); + var closeButtons = dialog.querySelectorAll('[data-aksrt-dialog-close]'); + submitButtons.forEach(function (button) { buttons.push(button); }); + closeButtons.forEach(function (button) { + if (buttons.indexOf(button) === -1) buttons.push(button); + }); + return buttons; + } + + function setActionsDisabled(disabled) { + actionButtons().forEach(function (button) { button.disabled = disabled; }); + } + + function setBusyLabel(form) { + var submit = form.querySelector('[data-aksrt-verify-submit]'); + if (submit) submit.textContent = valueOf(submit, 'data-busy-label', submit.textContent); + } + + function syncReviewIfIdle(form) { + if (!submitting && form.dataset.submitting !== '1') syncReview(form); + } + + function setupForm(form) { + var review = form.hasAttribute('data-aksrt-verify-review'); + var deletion = form.hasAttribute('data-aksrt-verify-delete-form'); + var submit = form.querySelector('[data-aksrt-verify-submit]'); + if (!review && !deletion) return; + + if (review) { + resetReview(form); + form.addEventListener('change', function () { syncReviewIfIdle(form); }); + form.addEventListener('input', function () { syncReviewIfIdle(form); }); + } else if (submit) { + submit.disabled = false; + submit.textContent = valueOf(submit, 'data-idle-label', submit.textContent); + submit.classList.add('is-danger'); + } + + form.addEventListener('submit', function (event) { + if (submitting || form.dataset.submitting === '1') { + prevent(event); + return; + } + + if (review) syncReview(form); + var valid = typeof form.reportValidity !== 'function' || form.reportValidity(); + if (!valid || (review && !selectedStatus(form))) { + prevent(event); + return; + } + + submitting = true; + form.dataset.submitting = '1'; + setActionsDisabled(true); + setBusyLabel(form); + lockScroll(); + }); + } + + function openTemplate(button, prefix) { + if (submitting || dialog.open) return; + var id = button.getAttribute(prefix); + if (id === null || id === '') return; + var template = document.getElementById('aksrt-verify-' + (prefix === 'data-aksrt-verify-view' ? 'detail-' : 'delete-') + id); + if (!template || !template.content || typeof template.content.cloneNode !== 'function') return; + + pointerStartedOutside = false; + dialog.replaceChildren(template.content.cloneNode(true)); + opener = button; + activeForm = dialog.querySelector('form[data-aksrt-verify-review]') + || dialog.querySelector('form[data-aksrt-verify-delete-form]'); + if (activeForm) setupForm(activeForm); + + dialog.showModal(); + lockScroll(); + } + + root.querySelectorAll('[data-aksrt-verify-view]').forEach(function (button) { + button.addEventListener('click', function (event) { + prevent(event); + openTemplate(button, 'data-aksrt-verify-view'); + }); + }); + root.querySelectorAll('[data-aksrt-verify-delete]').forEach(function (button) { + button.addEventListener('click', function (event) { + prevent(event); + openTemplate(button, 'data-aksrt-verify-delete'); + }); + }); + + dialog.addEventListener('click', function (event) { + var closeButton = closestAttribute(event.target, 'data-aksrt-dialog-close', dialog); + if (closeButton) { + prevent(event); + if (!submitting) dialog.close(); + pointerStartedOutside = false; + return; + } + if (pointerStartedOutside && event.target === dialog && isOutside(event) && !submitting) dialog.close(); + pointerStartedOutside = false; + }); + + dialog.addEventListener('pointerdown', function (event) { + pointerStartedOutside = event.target === dialog && isOutside(event); + }); + + dialog.addEventListener('cancel', function (event) { + prevent(event); + if (!submitting) dialog.close(); + }); + + dialog.addEventListener('keydown', function (event) { + if (event.key === 'Escape' && submitting) { + prevent(event); + if (typeof event.stopPropagation === 'function') event.stopPropagation(); + } + }); + + dialog.addEventListener('close', function () { + dialog.replaceChildren(); + activeForm = null; + submitting = false; + pointerStartedOutside = false; + lockScroll(); + var previousOpener = opener; + opener = null; + if (previousOpener && previousOpener.isConnected) previousOpener.focus(); + }); + + window.addEventListener('pageshow', function () { + submitting = false; + if (activeForm) { + delete activeForm.dataset.submitting; + setActionsDisabled(false); + if (activeForm.hasAttribute('data-aksrt-verify-review')) { + syncReview(activeForm); + } else { + var submit = activeForm.querySelector('[data-aksrt-verify-submit]'); + if (submit) { + submit.disabled = false; + submit.textContent = valueOf(submit, 'data-idle-label', submit.textContent); + submit.classList.add('is-danger'); + } + } + } + lockScroll(); + }); +})(); diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index 74414d09..13a651ce 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -46,6 +46,7 @@ class UsersMenus { add_action('admin_enqueue_scripts', [$this, 'enqueue_ticket_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_message_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_invite_assets']); + add_action('admin_enqueue_scripts', [$this, 'enqueue_verify_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_vip_assets']); } diff --git a/src/Admin/Native/UsersMenusVerify.php b/src/Admin/Native/UsersMenusVerify.php index 8cd46727..594f1b16 100644 --- a/src/Admin/Native/UsersMenusVerify.php +++ b/src/Admin/Native/UsersMenusVerify.php @@ -3,169 +3,218 @@ namespace AKSRT\Admin\Native; +use AKSRT\Identity\ApplicationRepository; +use AKSRT\Identity\EvidenceService; +use AKSRT\Identity\EventRepository; +use AKSRT\User\AuthApply; + defined('ABSPATH') || exit; trait UsersMenusVerify { + use UsersMenusVerifyDialogs; + + private const VERIFY_PER_PAGE = 20; + private const VERIFY_STATUSES = ['pending', 'approved', 'rejected', '']; + + public function enqueue_verify_assets(string $hook): void { + if ($hook !== 'users_page_aksrt_user_auth') return; + $css = '/assets/css/admin/user-auth.css'; + $js = '/assets/js/admin-user-auth.js'; + wp_enqueue_style('aksrt-admin-user-auth', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css)); + wp_enqueue_script('aksrt-admin-user-auth', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true); + } private function count_verify_status(string $status): int { global $wpdb; $table = \AKSRT\Core\DBMigrator::table('identity_application'); - $stored = $status === 'pending' ? 'manual_pending' : $status; return (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE type = %s AND channel = %s AND status = %s", - 'general', - 'manual', - $stored + 'general', 'manual', $status === 'pending' ? 'manual_pending' : $status )); } public function render_verify_page(): void { $this->ensure_cap(); - $filters = $this->get_verify_filters(); - $page = max(1, (int) ($_GET['paged'] ?? 1)); - $data = $this->query_verify_requests($filters, $page, 20); - $counts = $this->get_verify_status_counts(); - NativeUi::page_open(); - NativeUi::header(__('身份认证', 'aksrt'), '', 'dashicons-id'); + $filters = $this->get_verify_filters($_GET); + $data = $this->query_verify_requests($filters, max(1, (int) ($_GET['paged'] ?? 1))); + $counts = $this->get_verify_status_counts($filters); + NativeUi::page_open('aksrt-verify-page'); + NativeUi::header( + __('身份认证', 'aksrt'), + __('查看申请资料与证明材料,在审核弹窗中确认处理结果。', 'aksrt'), + 'dashicons-id', + [['label' => __('认证设置', 'aksrt'), 'url' => admin_url('admin.php?page=aksrt-settings#/users/verify'), 'icon' => 'dashicons-admin-settings']] + ); $this->flash(); - NativeUi::stats([ - ['label' => __('待审核', 'aksrt'), 'value' => $counts['pending'], 'icon' => 'dashicons-clock', 'tone' => 'warning'], - ['label' => __('已通过', 'aksrt'), 'value' => $counts['approved'], 'icon' => 'dashicons-yes-alt', 'tone' => 'success'], - ['label' => __('已拒绝', 'aksrt'), 'value' => $counts['rejected'], 'icon' => 'dashicons-dismiss', 'tone' => 'danger'], - ]); - NativeUi::card_open('', '', false, 'aksrt-native-toolbar'); - $this->render_verify_filters($filters); - NativeUi::card_close(); - NativeUi::table_card_open(__('认证申请', 'aksrt')); - $this->render_verify_table($data['items']); + NativeUi::card_open('', '', true, 'aksrt-verify-list-card'); + $this->render_verify_filters($filters, $counts); + echo '
'; + $this->render_verify_table($data['items'], $filters, $data['page']); NativeUi::table_wrap_close(); - $this->pagination($data['pages'], $page); + AdminListPagination::render('aksrt_user_auth', $data['total'], $data['page'], self::VERIFY_PER_PAGE, $filters, 'users.php'); NativeUi::card_close(); + echo ''; + echo ''; NativeUi::page_close(); } /** @return array{status:string,keyword:string} */ - private function get_verify_filters(): array { - $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : 'pending'; - if (!in_array($status, ['pending', 'approved', 'rejected', ''], true)) $status = 'pending'; + private function get_verify_filters(array $input): array { + $status = isset($input['status']) ? sanitize_key((string) wp_unslash($input['status'])) : 'pending'; return [ - 'status' => $status, - 'keyword' => isset($_GET['keyword']) ? sanitize_text_field((string) wp_unslash($_GET['keyword'])) : '', + 'status' => in_array($status, self::VERIFY_STATUSES, true) ? $status : 'pending', + 'keyword' => sanitize_text_field((string) wp_unslash($input['keyword'] ?? '')), ]; } - /** @return array{pending:int,approved:int,rejected:int} */ - private function get_verify_status_counts(): array { - return [ - 'pending' => $this->count_verify_status('pending'), - 'approved' => $this->count_verify_status('verified'), - 'rejected' => $this->count_verify_status('rejected'), - ]; - } - - /** @return array{items:array>,pages:int} */ - private function query_verify_requests(array $filters, int $page, int $per_page): array { + /** @return array{0:string,1:array} */ + private function verify_query_base(array $filters): array { global $wpdb; $table = \AKSRT\Core\DBMigrator::table('identity_application'); - $where = ["a.type = 'general'", "a.channel = 'manual'"]; - $params = []; + $where = ['a.type = %s', 'a.channel = %s']; + $params = ['general', 'manual']; if ($filters['status'] !== '') { $where[] = 'a.status = %s'; - $params[] = $filters['status'] === 'pending' ? 'manual_pending' : ($filters['status'] === 'approved' ? 'verified' : $filters['status']); + $params[] = ['pending' => 'manual_pending', 'approved' => 'verified', 'rejected' => 'rejected'][$filters['status']]; } if ($filters['keyword'] !== '') { $like = '%' . $wpdb->esc_like($filters['keyword']) . '%'; $where[] = '(CAST(u.ID AS CHAR) LIKE %s OR u.display_name LIKE %s OR u.user_login LIKE %s OR u.user_email LIKE %s OR a.public_name LIKE %s OR a.provider_result_summary LIKE %s)'; array_push($params, $like, $like, $like, $like, $like, $like); } - $condition = implode(' AND ', $where); - $base = "FROM {$table} a INNER JOIN {$wpdb->users} u ON u.ID = a.user_id WHERE {$condition}"; - $count_sql = "SELECT COUNT(*) {$base}"; - $total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql); - $limit = max(1, min(100, $per_page)); - $query_params = array_merge($params, [$limit, (max(1, $page) - 1) * $limit]); - $sql = "SELECT a.id, a.application_uuid, a.user_id, a.type, a.channel, a.status, a.public_name, a.provider_result_summary AS description, a.manual_reason, a.review_note, a.evidence_ids, a.created_at, u.display_name, u.user_login, u.user_email {$base} ORDER BY a.created_at DESC, a.id DESC LIMIT %d OFFSET %d"; + return ["FROM {$table} a INNER JOIN {$wpdb->users} u ON u.ID = a.user_id WHERE " . implode(' AND ', $where), $params]; + } + + /** @return array{pending:int,approved:int,rejected:int,all:int} */ + private function get_verify_status_counts(array $filters): array { + global $wpdb; + $filters['status'] = ''; + [$base, $params] = $this->verify_query_base($filters); + $rows = $wpdb->get_results($wpdb->prepare("SELECT a.status, COUNT(*) AS total {$base} GROUP BY a.status", $params), ARRAY_A); + $counts = ['pending' => 0, 'approved' => 0, 'rejected' => 0, 'all' => 0]; + $keys = ['manual_pending' => 'pending', 'verified' => 'approved', 'rejected' => 'rejected']; + foreach ((array) $rows as $row) { + $total = (int) $row['total']; + $counts['all'] += $total; + if (isset($keys[$row['status']])) $counts[$keys[$row['status']]] = $total; + } + return $counts; + } + + /** @return array{items:array,page:int,total:int} */ + private function query_verify_requests(array $filters, int $page): array { + global $wpdb; + [$base, $params] = $this->verify_query_base($filters); + $total = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) {$base}", $params)); + $page = min(max(1, $page), max(1, (int) ceil($total / self::VERIFY_PER_PAGE))); + $query_params = array_merge($params, [self::VERIFY_PER_PAGE, ($page - 1) * self::VERIFY_PER_PAGE]); + $sql = "SELECT a.id, a.application_uuid, a.user_id, a.status, a.public_name, a.provider_result_summary AS description, a.manual_reason, a.review_note, a.reviewer_id, a.evidence_ids, a.created_at, a.completed_at, u.display_name, u.user_login, u.user_email {$base} ORDER BY a.created_at DESC, a.id DESC LIMIT %d OFFSET %d"; $rows = $wpdb->get_results($wpdb->prepare($sql, $query_params), ARRAY_A); - return ['items' => is_array($rows) ? $rows : [], 'pages' => max(1, (int) ceil(max(1, $total) / $limit))]; + $items = is_array($rows) ? $rows : []; + $users = []; + $evidence = []; + foreach ($items as &$row) { + $ids = json_decode((string) $row['evidence_ids'], true); + $row['evidence_ids'] = is_array($ids) ? array_values(array_filter(array_map('absint', $ids))) : []; + array_push($evidence, ...$row['evidence_ids']); + $users[] = (int) $row['user_id']; + $users[] = (int) $row['reviewer_id']; + } + unset($row); + $users = array_values(array_unique(array_filter($users))); + if ($users !== []) cache_users($users); + if ($evidence !== []) update_meta_cache('post', array_values(array_unique($evidence))); + return ['items' => $items, 'page' => $page, 'total' => $total]; } - private function render_verify_filters(array $filters): void { - echo '
'; - echo ''; - echo ''; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + if ($filters['keyword'] !== '') { + echo '' . esc_html__('清除搜索', 'aksrt') . ''; + } + echo '
'; } - private function render_verify_table(array $items): void { - echo ''; - if ($items === []) { - echo NativeUi::empty_row(6, __('暂无认证申请', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + private function render_verify_table(array $items, array $filters, int $page): void { + echo '
用户状态认证名称认证简介 / 申请说明证明材料审核
'; + foreach (['user' => __('申请用户', 'aksrt'), 'profile' => __('认证资料', 'aksrt'), 'evidence' => __('证明材料', 'aksrt'), 'status' => __('状态', 'aksrt'), 'time' => __('申请时间', 'aksrt'), 'actions' => __('操作', 'aksrt')] as $key => $label) { + echo ''; } - foreach ($items as $row) $this->render_verify_row($row); + echo ''; + if ($items === []) { + $title = $filters['status'] === 'pending' && $filters['keyword'] === '' ? __('暂无待审核申请', 'aksrt') : __('没有符合条件的认证申请', 'aksrt'); + $hint = $filters['keyword'] !== '' ? __('请调整搜索关键词或切换状态。', 'aksrt') : __('可切换状态查看其他申请,新申请将在这里显示。', 'aksrt'); + echo NativeUi::empty_row(6, $title, $hint); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + } + foreach ($items as $row) $this->render_verify_row($row, $filters, $page); echo '
' . esc_html($label) . '
'; } - private function render_verify_row(array $row): void { - $user_id = (int) $row['user_id']; - $status = (string) $row['status']; - $application = (new \AKSRT\Identity\ApplicationRepository())->find_by_id((int) $row['id']) ?: $row; - echo '' . NativeUi::user_cell($user_id, (string) $row['display_name']) . '
' . esc_html((string) $row['user_email']) . '
'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. - echo '' . NativeUi::status_badge($status, [ + private function render_verify_row(array $row, array $filters, int $page): void { + $id = (int) $row['id']; + $pending = $row['status'] === 'manual_pending'; + $evidence = (new EvidenceService())->items($row, true); + echo '' . NativeUi::user_cell((int) $row['user_id'], (string) $row['display_name']); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + echo '
' . esc_html((string) $row['user_email']) . '
'; + echo '' . esc_html((string) $row['public_name']) . '
' . esc_html((string) $row['description']) . '
'; + $this->render_verify_detail_template($row, $evidence, $filters, $page); + $this->render_verify_delete_template($row, $filters, $page); + echo '' . esc_html($evidence === [] ? __('未提供材料', 'aksrt') : sprintf(__('%d 份材料', 'aksrt'), count($evidence))) . ''; + echo '' . $this->verify_status_badge((string) $row['status']) . ''; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. + echo '' . esc_html($this->verify_time((string) $row['created_at'])) . ''; + echo '
'; + echo ''; + echo '
'; + } + + private function verify_status_badge(string $status): string { + return NativeUi::status_badge($status, [ 'manual_pending' => [__('待审核', 'aksrt'), 'warning'], 'verified' => [__('已通过', 'aksrt'), 'success'], 'rejected' => [__('已拒绝', 'aksrt'), 'danger'], - ]) . '
' . esc_html((string) $row['created_at']) . '
'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes. - echo '' . esc_html((string) $row['public_name']) . ''; - echo '' . esc_html((string) $row['description']) . '

' . esc_html((string) $row['manual_reason']) . '

'; - if (!empty($row['review_note'])) echo '

' . esc_html__('处理留言:', 'aksrt') . esc_html((string) $row['review_note']) . '

'; - echo ''; - foreach ((new \AKSRT\Identity\EvidenceService())->items($application, true) as $item) { - echo ''; - } - echo ''; - $this->render_verify_actions($row); - echo ''; + ]); } - private function render_verify_actions(array $row): void { - $status = (string) $row['status']; - if ($status === 'manual_pending') { - foreach (['verified' => __('通过申请', 'aksrt'), 'rejected' => __('拒绝申请', 'aksrt')] as $next => $label) { - echo '
'; - echo ''; - wp_nonce_field('aksrt_users_review_verify_' . (int) $row['user_id']); - echo ''; - echo ''; - echo ''; - echo '
'; - } - } else { - echo '' . esc_html__('已处理', 'aksrt') . ''; - } - echo '
'; - echo ''; - wp_nonce_field('aksrt_users_delete_verify_' . (string) $row['application_uuid']); - echo '
'; + private function verify_time(string $utc): string { + return $utc !== '' && $utc !== '0000-00-00 00:00:00' ? get_date_from_gmt($utc, 'Y-m-d H:i') : '—'; + } + + /** @return array{status:string,keyword:string,paged:int} */ + private function verify_return_args(): array { + return array_merge($this->get_verify_filters(['status' => $_POST['filter_status'] ?? 'pending', 'keyword' => $_POST['keyword'] ?? '']), [ + 'paged' => max(1, (int) ($_POST['paged'] ?? 1)), + ]); } public function handle_review_verify(): void { if (!current_user_can('aksrt_review_identity') && !current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt')); - $user_id = absint($_POST['user_id'] ?? 0); - check_admin_referer('aksrt_users_review_verify_' . $user_id); $uuid = sanitize_text_field((string) wp_unslash($_POST['application_uuid'] ?? '')); + check_admin_referer('aksrt_users_review_verify_' . $uuid); + $user_id = absint($_POST['user_id'] ?? 0); $status = sanitize_key((string) wp_unslash($_POST['status'] ?? '')); $note = sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? '')); $fields = [ 'name' => sanitize_text_field((string) wp_unslash($_POST['name'] ?? '')), 'description' => sanitize_textarea_field((string) wp_unslash($_POST['description'] ?? '')), ]; - $result = \AKSRT\User\AuthApply::review($user_id, $uuid, $status, $note, get_current_user_id(), $fields); - $extra = ['status' => 'pending']; + $result = AuthApply::review($user_id, $uuid, $status, $note, get_current_user_id(), $fields); + $extra = $this->verify_return_args(); if (is_wp_error($result)) $extra['aksrt_verify_error'] = $result->get_error_message(); $this->redirect('aksrt_user_auth', is_wp_error($result) ? 'verify_failed' : 'verify_updated', $extra); } @@ -174,21 +223,19 @@ trait UsersMenusVerify { if (!current_user_can('aksrt_review_identity') && !current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt')); $uuid = sanitize_text_field((string) wp_unslash($_POST['application_uuid'] ?? '')); check_admin_referer('aksrt_users_delete_verify_' . $uuid); - $repo = new \AKSRT\Identity\ApplicationRepository(); + $extra = $this->verify_return_args(); + $repo = new ApplicationRepository(); $application = $repo->find_by_uuid($uuid); - if ( - !$application - || (string) ($application['type'] ?? '') !== 'general' - || (string) ($application['channel'] ?? '') !== 'manual' - ) { - $this->redirect('aksrt_user_auth', 'verify_failed', ['aksrt_verify_error' => __('认证记录不存在', 'aksrt')]); + if (!$application) { + $this->redirect('aksrt_user_auth', 'verify_failed', array_merge($extra, ['aksrt_verify_error' => __('认证记录不存在', 'aksrt')])); } - $deleted = $repo->delete((int) $application['id']); - if (!$deleted) { - $this->redirect('aksrt_user_auth', 'verify_failed', ['aksrt_verify_error' => __('认证记录删除失败', 'aksrt')]); + if (!$repo->delete((int) $application['id'])) { + $this->redirect('aksrt_user_auth', 'verify_failed', array_merge($extra, ['aksrt_verify_error' => __('认证记录删除失败', 'aksrt')])); } - (new \AKSRT\Identity\EvidenceService())->delete((array) ($application['evidence_ids'] ?? [])); - (new \AKSRT\Identity\EventRepository())->delete_for_application((int) $application['id']); - $this->redirect('aksrt_user_auth', 'verify_deleted'); + (new EvidenceService())->delete((array) $application['evidence_ids']); + if (!(new EventRepository())->delete_for_application((int) $application['id'])) { + $this->redirect('aksrt_user_auth', 'verify_failed', array_merge($extra, ['aksrt_verify_error' => __('认证申请记录已删除,但审核事件清理失败。', 'aksrt')])); + } + $this->redirect('aksrt_user_auth', 'verify_deleted', $extra); } } diff --git a/src/Admin/Native/UsersMenusVerifyDialogs.php b/src/Admin/Native/UsersMenusVerifyDialogs.php new file mode 100644 index 00000000..a8fc3d04 --- /dev/null +++ b/src/Admin/Native/UsersMenusVerifyDialogs.php @@ -0,0 +1,124 @@ +

' . esc_html($title) . '

'; + echo '

' . esc_html($description) . '

'; + echo ''; + } + + private function render_verify_context(array $row, array $filters, int $page, string $action): void { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + wp_nonce_field($action . '_' . (string) $row['application_uuid']); + } + + private function render_verify_detail_template(array $row, array $evidence, array $filters, int $page): void { + $pending = $row['status'] === 'manual_pending'; + echo ''; + } + + private function render_verify_text_section(string $title, string $text): void { + echo '

' . esc_html($title) . '

' . esc_html($text !== '' ? $text : __('未填写', 'aksrt')) . '
'; + } + + private function render_verify_evidence(array $evidence): void { + echo '

' . esc_html__('证明材料', 'aksrt') . '

'; + if ($evidence === []) { + echo '

' . esc_html__('未提供可查看的证明材料,请结合申请说明判断。', 'aksrt') . '

'; + } else { + echo '
    '; + foreach ($evidence as $index => $item) { + $label = sprintf(__('材料 %d(新窗口查看原图)', 'aksrt'), $index + 1); + echo '
  • ' . esc_attr(sprintf(__('证明材料 %d', 'aksrt'), $index + 1)) . '' . esc_html($label) . '
  • '; + } + echo '
'; + } + echo '
'; + } + + private function render_verify_review_fields(array $row): void { + echo '

' . esc_html__('审核处理', 'aksrt') . '

'; + echo '
' . esc_html__('审核结果(必选)', 'aksrt') . ''; + foreach (['verified' => __('通过申请', 'aksrt'), 'rejected' => __('拒绝申请', 'aksrt')] as $status => $label) { + echo ''; + } + echo '
'; + echo ''; + echo '

' . esc_html__('审核结果会通知申请用户;拒绝时请说明原因,便于用户修改后重新申请。', 'aksrt') . '

'; + } + + private function render_verify_delete_template(array $row, array $filters, int $page): void { + echo ''; + } +} diff --git a/src/Identity/EvidenceService.php b/src/Identity/EvidenceService.php index b696d4cc..076bab9d 100644 --- a/src/Identity/EvidenceService.php +++ b/src/Identity/EvidenceService.php @@ -83,7 +83,7 @@ final class EvidenceService { /** @return array */ public function items(array $application, bool $admin = false): array { $owner_id = (int) ($application['user_id'] ?? 0); - if ($owner_id <= 0 || ($admin ? !current_user_can('aksrt_review_identity') : get_current_user_id() !== $owner_id)) { + if ($owner_id <= 0 || ($admin ? !$this->can_review() : get_current_user_id() !== $owner_id)) { return []; } $items = []; @@ -111,7 +111,7 @@ final class EvidenceService { wp_die(esc_html__('安全校验失败', 'aksrt'), '', ['response' => 403]); } $owner = (int) get_post_meta($id, self::OWNER_META, true); - if ($owner <= 0 || (get_current_user_id() !== $owner && !current_user_can('aksrt_review_identity'))) { + if ($owner <= 0 || (get_current_user_id() !== $owner && !$this->can_review())) { wp_die(esc_html__('您没有权限查看该证明材料', 'aksrt'), '', ['response' => 403]); } $path = get_attached_file($id); @@ -132,6 +132,10 @@ final class EvidenceService { exit; } + private function can_review(): bool { + return current_user_can('aksrt_review_identity') || current_user_can('manage_options'); + } + /** @return array>|\WP_Error */ private function normalize($input) { if (!is_array($input) || empty($input['name'])) { diff --git a/tests/widget-system/user-auth-admin-contract.test.cjs b/tests/widget-system/user-auth-admin-contract.test.cjs new file mode 100644 index 00000000..84f9cee1 --- /dev/null +++ b/tests/widget-system/user-auth-admin-contract.test.cjs @@ -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(`]*\\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('', dialogStart); + assert.ok(cardStart >= 0 && cardEnd > cardStart, 'verification list card wraps its content'); + const card = html.slice(cardStart, cardEnd + ''.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, //); + 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 页,共 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, /', + }); +}); + +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\)/); +}); diff --git a/tests/widget-system/user-auth-admin-harness.php b/tests/widget-system/user-auth-admin-harness.php new file mode 100644 index 00000000..9e4d8ad2 --- /dev/null +++ b/tests/widget-system/user-auth-admin-harness.php @@ -0,0 +1,518 @@ +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 = ''; + 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 ', + 42 => 'Approved User', + 9 => 'Reviewer ', + ]; + $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 ', + 'description' => 'Bio & text', + 'manual_reason' => 'Reason ', + 'review_note' => '', + 'reviewer_id' => 0, + 'evidence_ids' => '[1001]', + 'created_at' => '2026-10-30 03:30:00', + 'completed_at' => null, + 'display_name' => 'Applicant ', + 'user_login' => 'applicant"', + 'user_email' => 'applicant"@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 &_%', + '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 & explain', + 'name' => 'Ignored ', + 'description' => 'Ignored description', + 'filter_status' => 'approved', + 'keyword' => 'review & 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', ''); + 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); +} diff --git a/tests/widget-system/user-auth-admin-runtime.test.cjs b/tests/widget-system/user-auth-admin-runtime.test.cjs new file mode 100644 index 00000000..82a993c2 --- /dev/null +++ b/tests/widget-system/user-auth-admin-runtime.test.cjs @@ -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 = '') { + 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, //); + 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); +});