Files
wordpress-theme-aksrt/tests/widget-system/user-auth-admin-contract.test.cjs
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00

272 lines
14 KiB
JavaScript
Raw Permalink 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="jio-native-card jio-native-card--flush jio-verify-list-card"');
const dialogStart = html.indexOf('<dialog id="jio-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(/jio-verify-list-card/g) || []).length, 1);
for (const marker of ['class="subsubsub"', 'class="jio-native-filters"', 'jio-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 jio-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="jio-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-jio-verify-review/);
assert.equal((html.match(/data-jio-verify-review/g) || []).length, 1);
assert.equal((html.match(/data-jio-verify-delete-form/g) || []).length, 2);
assert.match(html, /data-jio-verify-view="301"/);
assert.match(html, /data-jio-verify-view="302"/);
});
test('detail and delete templates bind UUID nonces, and processed records are read-only', () => {
const { html } = harness();
const pending = template(html, 'jio-verify-detail-301');
const pendingDelete = template(html, 'jio-verify-delete-301');
const approved = template(html, 'jio-verify-detail-302');
const approvedDelete = template(html, 'jio-verify-delete-302');
assert.match(pending, /name="application_uuid" value="uuid-pending-301"/);
assert.match(pending, /name="_wpnonce" value="nonce:jio_users_review_verify_uuid-pending-301"/);
assert.match(pendingDelete, /name="_wpnonce" value="nonce:jio_users_delete_verify_uuid-pending-301"/);
assert.match(approvedDelete, /name="_wpnonce" value="nonce:jio_users_delete_verify_uuid-approved-302"/);
assert.doesNotMatch(approved, /<form\b|data-jio-verify-review/);
assert.match(approved, /Reviewed safely/);
assert.match(approved, /2026-10-30 12:45/);
assert.doesNotMatch(pending + pendingDelete + approvedDelete, /jio_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, 'jio-verify-detail-301');
assert.match(pending, /name="status" value="verified" required/);
assert.match(pending, /name="status" value="rejected" required/);
assert.match(pending, /data-jio-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, 'jio-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'), 'jio_verify_evidence');
assert.equal(url.searchParams.get('attachment'), '1001');
assert.equal(url.searchParams.get('_wpnonce'), 'nonce:jio_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], ['jio_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: 'jio_user_auth',
jio_users_msg: 'verify_updated',
status: 'approved',
keyword: 'review search & phrase',
paged: '4',
});
assert.deepEqual(data.review_error_redirect, {
page: 'jio_user_auth',
jio_users_msg: 'verify_failed',
status: 'rejected',
keyword: 'keep this',
paged: '2',
jio_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: 'jio_user_auth',
jio_users_msg: 'verify_deleted',
status: 'rejected',
keyword: 'delete search',
paged: '6',
});
assert.deepEqual(data.delete_success_calls, [
{ table: 'wp_jio_identity_application', data: { id: 501, type: 'general', channel: 'manual' }, format: ['%d', '%s', '%s'] },
{ table: 'wp_jio_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: 'jio_user_auth',
jio_users_msg: 'verify_failed',
status: 'pending',
keyword: 'delete failure',
paged: '1',
jio_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: 'jio_user_auth',
jio_users_msg: 'verify_failed',
status: 'rejected',
keyword: 'delete search',
paged: '6',
jio_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', 'jio-admin-user-auth', 'https://example.test/theme/assets/css/admin/user-auth.css', ['jio-admin-native-ui'],
]);
assert.deepEqual(data.auth_page_assets[1].slice(0, 4), [
'script', 'jio-admin-user-auth', 'https://example.test/theme/assets/js/admin-user-auth.js', [],
]);
assert.match(source.verify(), /if \(\$hook !== 'users_page_jio_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\('jio_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\('jio_users_review_verify_' \. \$uuid\)/);
assert.match(verify, /check_admin_referer\('jio_users_delete_verify_' \. \$uuid\)/);
assert.doesNotMatch(verify, /check_admin_referer\('jio_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\['jio_verify_error'\]\)\)/);
assert.match(menus, /esc_html\(\$message\)/);
});