Files
wordpress-theme-aksrt/tests/widget-system/ban-admin-contract.test.cjs
T
kate522andClaude Fable 5 b45fd161f0 fix(reports): separate readable categories from report content
Store readable category snapshots separately from details and evidence, validate report categories, and preserve context in admin displays and moderation actions. Widen report categories to TEXT and bump the schema to 3.2.0 without legacy data conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 00:57:13 +08:00

195 lines
9.8 KiB
JavaScript

'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 = (file) => fs.readFileSync(path.join(root, file), 'utf8');
let fixture;
function phpFixture() {
if (fixture) return fixture;
const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'ban-admin-harness.php')], {
cwd: root,
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
fixture = JSON.parse(result.stdout);
return fixture;
}
function inputValue(html, name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = html.match(new RegExp(`<input[^>]*name="${escaped}"[^>]*value="([^"]*)"`));
return match ? match[1] : null;
}
test('ban queue merges all records, reports status counts, and clamps pages above 100 rows', () => {
const result = phpFixture();
assert.equal(result.queue.total, 132);
assert.equal(result.queue.page, 2);
assert.equal(result.queue.items, 20);
assert.deepEqual(result.queue.counts, { all: 132, pending: 128, resolved: 2, dismissed: 2 });
assert.equal(result.queue.overlarge_page, 7);
assert.equal(result.queue.overlarge_items, 12);
assert.deepEqual(result.filters, {
type: 'report', status: '', s: 'needle', send_user: 7, orderby: 'updated', order: 'ASC',
});
});
test('real PHP rendering keeps review forms in templates and completed records read-only', () => {
const { html, filtered_html: filteredHtml } = phpFixture();
assert.equal((html.match(/<dialog id="aksrt-ban-dialog"/g) || []).length, 1);
assert.equal((html.match(/<template id="aksrt-ban-detail-/g) || []).length, 20);
assert.match(html, /<template id="aksrt-ban-detail-report-1">\s*<form method="post"/);
assert.match(html, /<template id="aksrt-ban-detail-appeal-501">\s*<form method="post"/);
const resolved = html.match(/<template id="aksrt-ban-detail-report-2">([\s\S]*?)<\/template>/)?.[1] || '';
assert.notEqual(resolved, '');
assert.doesNotMatch(resolved, /<form|data-aksrt-ban-submit/);
assert.match(resolved, /该记录已处理/);
for (const [name, value] of Object.entries({
action: 'aksrt_users_update_report', report_id: '9001', filter_type: 'report', filter_status: 'pending',
s: 'needle', send_user: '7', orderby: 'updated', order: 'ASC', paged: '1',
})) {
assert.equal(inputValue(filteredHtml, name), value, `review form should carry ${name}`);
}
assert.match(filteredHtml, /name="_wpnonce" value="aksrt_users_update_report_9001-nonce"/);
assert.match(html, /name="user_id" value="501"/);
assert.match(html, /name="_wpnonce" value="aksrt_users_process_appeal_501-nonce"/);
assert.match(html, /<input type="radio"[^>]*required>/);
assert.doesNotMatch(html, /<input type="radio"[^>]*checked/);
});
test('PHP templates escape all appeal data and report evidence while preserving safe links', () => {
const { html } = phpFixture();
assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/);
assert.match(html, /Name &lt;img src=x onerror=alert\(4\)&gt;/);
assert.match(html, /Applicant &lt;b&gt;value&lt;\/b&gt;/);
assert.match(html, /reason &lt;svg onload=alert\(1\)&gt;/);
assert.match(html, /href="https:\/\/evidence\.example\.test\/proof\.jpg" target="_blank" rel="noopener noreferrer"/);
assert.match(html, /href="https:\/\/appeal\.example\.test\/file\.pdf" target="_blank" rel="noopener noreferrer"/);
assert.match(html, /<dd>https:\/\/appeal\.example\.test\/document\.pdf<\/dd>/);
assert.doesNotMatch(html, /href="javascript:/i);
assert.doesNotMatch(html, /<img src=x onerror=/i);
assert.doesNotMatch(html, /<script>alert\(/i);
});
test('report details display readable categories separately from optional content and evidence', () => {
const { html } = phpFixture();
const detail = html.match(/<template id="aksrt-ban-detail-report-1">([\s\S]*?)<\/template>/)?.[1] || '';
const category = detail.match(/<h3>举报分类<\/h3><div class="aksrt-ban-copy">([\s\S]*?)<\/div>/)?.[1];
const content = detail.match(/<h3>举报内容与举证<\/h3><div class="aksrt-ban-copy">([\s\S]*?)<\/div>/)?.[1] || '';
assert.equal(category, '版权投诉');
assert.match(content, /Evidence/);
assert.match(content, /https:\/\/evidence\.example\.test\/proof\.jpg/);
assert.doesNotMatch(content, /版权投诉/);
assert.match(html, /class="aksrt-ban-summary">版权投诉<\/div>/);
const categoryOnly = html.match(/<template id="aksrt-ban-detail-report-2">([\s\S]*?)<\/template>/)?.[1] || '';
assert.match(categoryOnly, /<h3>举报分类<\/h3><div class="aksrt-ban-copy">人身攻击<\/div>/);
assert.match(categoryOnly, /<h3>举报内容与举证<\/h3><div class="aksrt-ban-copy">未填写<\/div>/);
assert.match(html, /class="aksrt-ban-summary">人身攻击<\/div>/);
});
test('dedicated ban assets enqueue only on the matching native page', () => {
const { assets } = phpFixture();
assert.equal(assets.styles.length, 1);
assert.deepEqual(assets.styles[0].slice(0, 3), [
'aksrt-admin-user-ban', 'https://theme.example.test/assets/css/admin/user-ban.css', ['aksrt-admin-native-ui'],
]);
assert.equal(assets.scripts.length, 1);
assert.deepEqual(assets.scripts[0].slice(0, 3), [
'aksrt-admin-user-ban', 'https://theme.example.test/assets/js/admin-user-ban.js', [],
]);
assert.equal(assets.scripts[0][4], true);
});
test('PHP actions reject blank notes and stale versions, and preserve filter roundtrips', () => {
const { actions } = phpFixture();
assert.equal(actions.blank_report.message, 'report_failed');
assert.match(actions.blank_report.args.aksrt_ban_error, /必须填写处理说明/);
assert.equal(actions.stale_report.message, 'report_failed');
assert.match(actions.stale_report.args.aksrt_ban_error, /已变化或已处理/);
assert.equal(actions.valid_report.message, 'report_updated');
assert.deepEqual({
type: actions.valid_report.args.type,
status: actions.valid_report.args.status,
s: actions.valid_report.args.s,
send_user: actions.valid_report.args.send_user,
orderby: actions.valid_report.args.orderby,
order: actions.valid_report.args.order,
paged: actions.valid_report.args.paged,
}, { type: 'report', status: 'pending', s: 'needle', send_user: 7, orderby: 'updated', order: 'ASC', paged: 2 });
assert.equal(actions.blank_appeal.message, 'appeal_failed');
assert.match(actions.blank_appeal.args.aksrt_ban_error, /必须填写拒绝原因/);
assert.equal(actions.valid_appeal.message, 'appeal_rejected');
assert.equal(actions.appeal_status, 'rejected');
assert.equal(actions.appeal_payload.admin_note, 'Missing evidence');
assert.equal(actions.approve_appeal.message, 'appeal_approved');
assert.equal(actions.approved_status, 'approved');
assert.equal(actions.restrictions_lifted, 1);
assert.deepEqual(actions.report_calls, [[9001, 'resolved', 'Reviewed']]);
assert.equal(actions.messages.length, 2);
});
test('ban admin contract keeps the single-dialog flow and separate native operations', () => {
const users = read('src/Admin/Native/UsersMenus.php');
const page = read('src/Admin/Native/UsersMenusBan.php');
const query = read('src/Admin/Native/UsersMenusBanQuery.php');
const actions = read('src/Admin/Native/UsersMenusBanActions.php');
const dialogs = read('src/Admin/Native/UsersMenusBanDialogs.php');
const script = read('assets/js/admin-user-ban.js');
const css = read('assets/css/admin/user-ban.css');
assert.match(users, /use UsersMenusBan;/);
assert.match(users, /admin_post_aksrt_users_update_report/);
assert.match(users, /admin_post_aksrt_users_process_appeal/);
assert.doesNotMatch(users, /use UsersMenusTickets;/);
assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTickets.php')));
assert.match(page, /if \(\$hook !== 'users_page_aksrt_user_ban'\) return/);
assert.match(page, /<dialog id="aksrt-ban-dialog"/);
assert.match(dialogs, /<template id="aksrt-ban-detail-/);
assert.doesNotMatch(page, /update_user_meta|update_status|do_action\(/);
assert.doesNotMatch(page, /data-aksrt-ticket-toggle|data-aksrt-ticket-cancel|data-aksrt-ban-toggle/);
assert.match(query, /BAN_PER_PAGE = 20/);
assert.match(query, /number'\s*=>\s*-1/);
assert.match(query, /array_slice\(\$filtered, \(\$page - 1\) \* self::BAN_PER_PAGE, self::BAN_PER_PAGE\)/);
assert.match(query, /'counts'\s*=>\s*\$counts/);
assert.match(query, /raw_status/);
assert.match(dialogs, /'action'\s*=>\s*\$action/);
assert.match(dialogs, /record_version/);
assert.match(dialogs, /wp_nonce_field\(\$action \. '_' \. \(int\) \$row\['id'\]\)/);
for (const name of ['filter_type', 'filter_status', 's', 'send_user', 'orderby', 'order', 'paged']) {
assert.ok(dialogs.includes(`'${name}'`), `missing hidden filter field ${name}`);
}
assert.match(dialogs, /esc_url\(\$part, \['http', 'https'\]\)/);
assert.match(dialogs, /esc_html\(\$value !== '' \? \$value : '—'\)/);
assert.match(dialogs, /target="_blank" rel="noopener noreferrer"/);
assert.match(actions, /check_admin_referer\('aksrt_users_update_report_'/);
assert.match(actions, /check_admin_referer\('aksrt_users_process_appeal_'/);
assert.match(actions, /hash_equals\(\$this->ban_record_version\(\$row\), \$submitted\)/);
assert.match(actions, /\$this->ban_request_is_current\(\$row, 'pending'\)/);
assert.match(actions, /\$decision === 'reject' && \$note === ''/);
assert.match(actions, /\$new_status === 'dismissed' && \$note === ''/);
assert.match(script, /dialog\.showModal\(\)/);
assert.match(script, /dialog\.replaceChildren\(\)/);
assert.match(script, /previousOpener\.focus\(\)/);
assert.match(script, /window\.addEventListener\('pageshow'/);
assert.match(css, /html\.aksrt-ban-modal-open \{ overflow: hidden/);
});