Files
wordpress-theme-aksrt/tests/widget-system/user-auth-admin-contract.test.cjs
T
kate522andClaude Fable 5 bc26d62602 feat(admin): redesign identity review with dialogs
Replace inline identity review forms with focused detail and review dialogs, improve list filters and caching, and align evidence permissions and deletion feedback. Add contract and runtime regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-30 20:05:21 +08:00

272 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const test = require('node:test');
const root = path.resolve(__dirname, '../..');
const read = (...segments) => fs.readFileSync(path.join(root, ...segments), 'utf8');
let result;
function harness() {
if (!result) {
const run = spawnSync('php', [path.join(__dirname, 'user-auth-admin-harness.php')], { encoding: 'utf8' });
assert.equal(run.status, 0, `${run.stderr}${run.stdout}`);
assert.equal(run.stderr, '', 'the PHP harness must not emit warnings');
result = JSON.parse(run.stdout);
}
return result;
}
function template(html, id) {
const safeId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = html.match(new RegExp(`<template\\b[^>]*\\bid="${safeId}"[^>]*>[\\s\\S]*?<\\/template>`));
assert.ok(match, `missing template ${id}`);
return match[0];
}
const source = {
verify: () => read('src', 'Admin', 'Native', 'UsersMenusVerify.php'),
dialogs: () => read('src', 'Admin', 'Native', 'UsersMenusVerifyDialogs.php'),
menus: () => read('src', 'Admin', 'Native', 'UsersMenus.php'),
authApply: () => read('src', 'User', 'AuthApply.php'),
applicationRepository: () => read('src', 'Identity', 'ApplicationRepository.php'),
};
test('default pending filters, keyword, status views, list and pagination share one card', () => {
const { html } = harness();
const cardStart = html.indexOf('class="aksrt-native-card aksrt-native-card--flush aksrt-verify-list-card"');
const dialogStart = html.indexOf('<dialog id="aksrt-verify-dialog"');
const cardEnd = html.lastIndexOf('</section>', dialogStart);
assert.ok(cardStart >= 0 && cardEnd > cardStart, 'verification list card wraps its content');
const card = html.slice(cardStart, cardEnd + '</section>'.length);
assert.equal((html.match(/aksrt-verify-list-card/g) || []).length, 1);
for (const marker of ['class="subsubsub"', 'class="aksrt-native-filters"', 'aksrt-verify-table', 'class="tablenav bottom"']) {
assert.ok(card.includes(marker), `${marker} should remain inside the list card`);
}
assert.match(card, /<table class="widefat fixed striped aksrt-verify-table">/);
assert.doesNotMatch(card, /class="wp-list-table/);
assert.match(card, /name="status" value="pending"/);
assert.match(card, /name="keyword" value="search alert\(1\) &amp;_%"/);
assert.match(card, /status=pending&amp;keyword=search\+alert%281%29\+%26_%25/);
assert.match(card, /class="current" aria-current="page">待审核/);
assert.match(card, /第 3 页,共 <span class="total-pages">3<\/span> 页/);
assert.match(card, /name="paged" value="3"/);
assert.doesNotMatch(card, /class="aksrt-verify-filter-summary"/);
});
test('rendered user/application data is escaped and row review forms exist only in inert templates', () => {
const { html } = harness();
assert.match(html, /Applicant &lt;script&gt;alert\(1\)&lt;\/script&gt;/);
assert.match(html, /Applicant name &lt;script&gt;alert\(3\)&lt;\/script&gt;/);
assert.match(html, /Bio &lt;img src=x onerror=alert\(4\)&gt; &amp; text/);
assert.match(html, /Reviewer &lt;img src=x onerror=alert\(2\)&gt;/);
assert.doesNotMatch(html, /<script>|<svg\b|<img[^>]*onerror=/i);
const outsideTemplates = html.replace(/<template\b[^>]*>[\s\S]*?<\/template>/g, '');
assert.doesNotMatch(outsideTemplates, /<form\b[^>]*data-aksrt-verify-review/);
assert.equal((html.match(/data-aksrt-verify-review/g) || []).length, 1);
assert.equal((html.match(/data-aksrt-verify-delete-form/g) || []).length, 2);
assert.match(html, /data-aksrt-verify-view="301"/);
assert.match(html, /data-aksrt-verify-view="302"/);
});
test('detail and delete templates bind UUID nonces, and processed records are read-only', () => {
const { html } = harness();
const pending = template(html, 'aksrt-verify-detail-301');
const pendingDelete = template(html, 'aksrt-verify-delete-301');
const approved = template(html, 'aksrt-verify-detail-302');
const approvedDelete = template(html, 'aksrt-verify-delete-302');
assert.match(pending, /name="application_uuid" value="uuid-pending-301"/);
assert.match(pending, /name="_wpnonce" value="nonce:aksrt_users_review_verify_uuid-pending-301"/);
assert.match(pendingDelete, /name="_wpnonce" value="nonce:aksrt_users_delete_verify_uuid-pending-301"/);
assert.match(approvedDelete, /name="_wpnonce" value="nonce:aksrt_users_delete_verify_uuid-approved-302"/);
assert.doesNotMatch(approved, /<form\b|data-aksrt-verify-review/);
assert.match(approved, /Reviewed safely/);
assert.match(approved, /2026-10-30 12:45/);
assert.doesNotMatch(pending + pendingDelete + approvedDelete, /aksrt_users_(?:review|delete)_verify_41/);
});
test('review UI exposes both decisions, conditional approval profile fields, and mandatory rejection note metadata', () => {
const { html } = harness();
const pending = template(html, 'aksrt-verify-detail-301');
assert.match(pending, /name="status" value="verified" required/);
assert.match(pending, /name="status" value="rejected" required/);
assert.match(pending, /data-aksrt-verify-profile hidden disabled/);
assert.match(pending, /name="name" maxlength="64"[^>]*required/);
assert.match(pending, /<textarea name="description" rows="3">/);
assert.match(pending, /name="note" rows="3"[^>]*data-required-error="请填写拒绝原因"/);
assert.match(pending, /data-required="拒绝原因(必填)"/);
});
test('proof links are private admin-post URLs protected by per-attachment nonces', () => {
const { html } = harness();
const pending = template(html, 'aksrt-verify-detail-301');
const link = pending.match(/<a href="([^"]+)" target="_blank" rel="noopener noreferrer"><img/);
assert.ok(link, 'evidence is displayed through a controlled link');
const url = new URL(link[1].replaceAll('&amp;', '&'));
assert.equal(url.origin, 'https://example.test');
assert.equal(url.pathname, '/wp-admin/admin-post.php');
assert.equal(url.searchParams.get('action'), 'aksrt_verify_evidence');
assert.equal(url.searchParams.get('attachment'), '1001');
assert.equal(url.searchParams.get('_wpnonce'), 'nonce:aksrt_verify_evidence_1001');
assert.doesNotMatch(link[1], /uploads|wp-content/i);
});
test('empty states, GMT conversion, clamped offset, prepared filters and batch caches are real trait behavior', () => {
const data = harness();
assert.match(data.pending_empty, /暂无待审核申请/);
assert.match(data.search_empty, /没有符合条件的认证申请/);
assert.match(data.search_empty, /请调整搜索关键词或切换状态/);
assert.match(data.html, /2026-10-30 11:30/);
assert.match(data.html, /2026-10-29 10:10/);
assert.equal(data.page_prepared.length, 3, 'status count, total and list queries are prepared once each');
const like = String.raw`%search alert(1) &\_\%%`;
for (const query of data.page_prepared) {
assert.deepEqual(query.params.slice(0, 2), ['general', 'manual']);
assert.match(query.sql, /a\.type = %s AND a\.channel = %s/);
assert.ok(query.params.includes(like), 'keyword wildcard characters are escaped before prepare');
}
const listQuery = data.page_prepared.find(({ sql }) => /LIMIT %d OFFSET %d/.test(sql));
assert.ok(listQuery, 'the list query has typed integer pagination placeholders');
assert.deepEqual(listQuery.params.slice(-2), [20, 40], 'requested page 999 is clamped to the final page before OFFSET');
assert.equal(listQuery.params[2], 'manual_pending', 'the pending view filters the stored status');
const groupedQuery = data.page_prepared.find(({ sql }) => /GROUP BY a.status/.test(sql));
assert.doesNotMatch(groupedQuery.sql, /a\.status = %s/, 'status view counts span every status for the same keyword');
assert.equal(data.page_executed.filter((sql) => /application_uuid\s*=/.test(sql)).length, 0, 'rendering does not run per-row find queries');
assert.deepEqual(data.cache_calls, [[41, 42, 9]], 'all applicant/reviewer user ids are primed in one batch');
assert.deepEqual(data.meta_cache_calls, [['post', [1001, 1002]]], 'evidence post meta is primed in one deduplicated batch');
});
test('review handler checks permission and UUID nonce, sanitizes payload, and preserves independent list filters', () => {
const data = harness();
assert.equal(data.permission_denied, true);
assert.equal(data.bad_nonce_rejected, true);
assert.deepEqual(data.review_nonce_calls[0], ['aksrt_users_review_verify_review-uuid-401', '_wpnonce']);
assert.deepEqual(data.review_call, {
user_id: 41,
application_uuid: 'review-uuid-401',
status: 'rejected',
note: 'Rejected reason & explain',
reviewer_id: 7,
profile_fields: { name: 'Ignored', description: 'Ignored description' },
});
assert.deepEqual(data.review_error_call, {
user_id: 41,
application_uuid: 'review-uuid-401',
status: 'verified',
note: '',
reviewer_id: 7,
profile_fields: { name: 'Safe name', description: 'Safe description' },
});
assert.deepEqual(data.review_redirect, {
page: 'aksrt_user_auth',
aksrt_users_msg: 'verify_updated',
status: 'approved',
keyword: 'review search & phrase',
paged: '4',
});
assert.deepEqual(data.review_error_redirect, {
page: 'aksrt_user_auth',
aksrt_users_msg: 'verify_failed',
status: 'rejected',
keyword: 'keep this',
paged: '2',
aksrt_verify_error: '<script>review unavailable</script>',
});
});
test('delete handler cleans attachments and events only after successful delete, without revoking auth projection', () => {
const data = harness();
assert.deepEqual(data.delete_success_redirect, {
page: 'aksrt_user_auth',
aksrt_users_msg: 'verify_deleted',
status: 'rejected',
keyword: 'delete search',
paged: '6',
});
assert.deepEqual(data.delete_success_calls, [
{ table: 'wp_aksrt_identity_application', data: { id: 501, type: 'general', channel: 'manual' }, format: ['%d', '%s', '%s'] },
{ table: 'wp_aksrt_identity_event', data: { application_id: 501 }, format: ['%d'] },
]);
assert.deepEqual(data.delete_success_attachments, [[1001, true], [1002, true]]);
assert.deepEqual(data.delete_auth_projection, { auth: 1, auth_info: { name: 'Already verified', desc: 'kept' } });
assert.deepEqual(data.delete_user_meta_calls, []);
assert.deepEqual(data.delete_failure_redirect, {
page: 'aksrt_user_auth',
aksrt_users_msg: 'verify_failed',
status: 'pending',
keyword: 'delete failure',
paged: '1',
aksrt_verify_error: '认证记录删除失败',
});
assert.equal(data.delete_failure_calls.length, 1, 'event rows are untouched after repository deletion fails');
assert.match(data.delete_failure_calls[0].table, /identity_application/);
assert.deepEqual(data.delete_failure_attachments, []);
});
test('proof listing and serving honor both reviewer capabilities without exposing other users’ evidence', () => {
const { evidence_permissions: permissions } = harness();
for (const role of ['manager', 'reviewer']) {
assert.equal(permissions[role].admin_items, 1);
assert.equal(permissions[role].owner_items, 0);
assert.equal(permissions[role].serve_result, '证明材料不存在', 'authorized users reach the file check');
}
assert.deepEqual(permissions.outsider, {
admin_items: 0, owner_items: 0, serve_result: '您没有权限查看该证明材料',
});
assert.deepEqual(permissions.owner, {
admin_items: 0, owner_items: 1, serve_result: '证明材料不存在',
});
});
test('event cleanup failure reports partial deletion instead of a false success', () => {
const data = harness();
assert.equal(data.delete_event_failure_calls.length, 2);
assert.deepEqual(data.delete_event_failure_redirect, {
page: 'aksrt_user_auth',
aksrt_users_msg: 'verify_failed',
status: 'rejected',
keyword: 'delete search',
paged: '6',
aksrt_verify_error: '认证申请记录已删除,但审核事件清理失败。',
});
});
test('assets are restricted to the authentication submenu and the stylesheet follows native UI', () => {
const data = harness();
assert.deepEqual(data.wrong_hook_assets, []);
assert.equal(data.auth_page_assets.length, 2);
assert.deepEqual(data.auth_page_assets[0].slice(0, 4), [
'style', 'aksrt-admin-user-auth', 'https://example.test/theme/assets/css/admin/user-auth.css', ['aksrt-admin-native-ui'],
]);
assert.deepEqual(data.auth_page_assets[1].slice(0, 4), [
'script', 'aksrt-admin-user-auth', 'https://example.test/theme/assets/js/admin-user-auth.js', [],
]);
assert.match(source.verify(), /if \(\$hook !== 'users_page_aksrt_user_auth'\) return/);
assert.match(source.menus(), /add_action\('admin_enqueue_scripts', \[\$this, 'enqueue_verify_assets'\]\)/);
});
test('the real AuthApply contract requires valid review data and a compare-and-swap from manual_pending', () => {
const auth = source.authApply();
const repository = source.applicationRepository();
assert.match(auth, /if \(!in_array\(\$status, \['verified', 'rejected'\], true\)\)/);
assert.match(auth, /if \(\$status === 'rejected' && \$note === ''\)/);
assert.match(auth, /\(int\) \$application\['user_id'\] !== \$user_id/);
assert.match(auth, /\(string\) \$application\['status'\] !== 'manual_pending'/);
assert.match(auth, /\$repo->transition\(\(int\) \$application\['id'\], 'manual_pending', \$status/);
assert.match(auth, /if \(!\$updated\) \{\s*return new \\WP_Error\('aksrt_auth_conflict'/);
assert.match(repository, /\['id' => \$id, 'type' => 'general', 'channel' => 'manual', 'status' => \$expected\]/);
});
test('review and deletion nonces use application UUIDs only, while failure notices are escaped by the shell', () => {
const verify = source.verify();
const dialogs = source.dialogs();
const menus = source.menus();
assert.match(verify, /check_admin_referer\('aksrt_users_review_verify_' \. \$uuid\)/);
assert.match(verify, /check_admin_referer\('aksrt_users_delete_verify_' \. \$uuid\)/);
assert.doesNotMatch(verify, /check_admin_referer\('aksrt_users_(?:review|delete)_verify_' \. \$user_id\)/);
assert.match(dialogs, /wp_nonce_field\(\$action \. '_' \. \(string\) \$row\['application_uuid'\]\)/);
assert.match(menus, /sanitize_text_field\(\(string\) wp_unslash\(\$_GET\['aksrt_verify_error'\]\)\)/);
assert.match(menus, /esc_html\(\$message\)/);
});