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>
166 lines
8.2 KiB
JavaScript
166 lines
8.2 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 工单 REST 权限契约:
|
||
* 1) 每条路由都带 permission_callback,没有裸路由;
|
||
* 2) 用户侧只能读写自己的工单(find_for_user 命中 user_id);
|
||
* 3) 关联订单必须属于本人且为已支付;
|
||
* 4) 管理侧全部要求 manage_options;
|
||
* 5) 附件上传复用评论图片上传,但权限回调是工单专属的。
|
||
*/
|
||
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const test = require('node:test');
|
||
|
||
const root = path.resolve(__dirname, '../..');
|
||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||
|
||
/** 从 register_routes() 源码里抽出所有 register_rest_route 调用的路由路径。 */
|
||
function routes(source) {
|
||
const out = [];
|
||
const re = /register_rest_route\(\s*\$namespace\s*,\s*([^,]+),\s*\[/g;
|
||
let m;
|
||
while ((m = re.exec(source)) !== null) out.push(m[1].trim());
|
||
return out;
|
||
}
|
||
|
||
test('every user-side ticket route declares a permission callback', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const body = rest.slice(rest.indexOf('public function register_routes()'), rest.indexOf('private function list_args()'));
|
||
|
||
const paths = routes(body);
|
||
assert.ok(paths.length >= 9, `expected at least 9 routes, found ${paths.length}`);
|
||
|
||
// 每个 register_rest_route 调用体内都必须出现 permission_callback。
|
||
const calls = body.match(/register_rest_route\([\s\S]*?\n\s*\]\);?/g) || [];
|
||
assert.equal(calls.length, paths.length, 'route call count mismatch');
|
||
for (const call of calls) {
|
||
assert.ok(call.includes('permission_callback'), `route without permission_callback:\n${call.slice(0, 160)}`);
|
||
}
|
||
|
||
// 用户侧统一走 require_user(登录 + nonce)。
|
||
assert.match(body, /'permission_callback' => \[\$this, 'require_user'\]/);
|
||
assert.match(rest, /is_user_logged_in\(\)/);
|
||
assert.match(rest, /wp_verify_nonce\(\$nonce, 'wp_rest'\)/);
|
||
// 附件上传用的是工单专属回调,不是 require_user。
|
||
assert.match(rest, /'permission_callback' => \[\$this, 'require_attachment_upload'\]/);
|
||
});
|
||
|
||
test('attachment upload permission is gated on the ticket attachment switch', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const body = rest.slice(rest.indexOf('public function require_attachment_upload'));
|
||
|
||
assert.match(body, /TicketSettings::enabled\(\)/);
|
||
assert.match(body, /TicketSettings::attachment_enabled\(\)/);
|
||
// 复用评论图片上传 handler,不自建上传链路;实例方法必须经单例调用(静态数组 callable 在 PHP 8 不可调用)。
|
||
assert.match(rest, /'callback' => \[\$this, 'upload_attachment'\]/);
|
||
assert.match(rest, /UserController::get_instance\(\)->rest_upload_comment_image\(\$request\)/);
|
||
assert.doesNotMatch(rest, /UserController::class, 'rest_upload_comment_image'/);
|
||
// 上传路由挂在 /ticket/upload-attachment(与 /report/upload-image 同构)。
|
||
assert.match(rest, /'\/ticket\/upload-attachment'/);
|
||
});
|
||
|
||
test('user reads and writes are scoped to the owner', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const repo = read('src/Ticket/TicketRepository.php');
|
||
|
||
// 详情 / 回复 / 催单 / 关闭全部经过 find_for_user。
|
||
assert.match(repo, /public function find_for_user\(int \$id, int \$user_id\): \?array/);
|
||
assert.match(repo, /if \(\$row === null \|\| \(int\) \$row\['user_id'\] !== \$user_id\) \{\s*\n\s*return null;/);
|
||
|
||
for (const method of ['get_ticket', 'reply', 'urge', 'close']) {
|
||
const body = rest.slice(rest.indexOf(`public function ${method}(`));
|
||
const next = body.indexOf('public function', 10);
|
||
const slice = next === -1 ? body : body.slice(0, next);
|
||
assert.match(slice, /find_for_user\(/, `${method} must scope by owner`);
|
||
}
|
||
|
||
// 列表与统计也带 user_id。
|
||
assert.match(rest, /'user_id' => get_current_user_id\(\),/);
|
||
});
|
||
|
||
test('linked orders must belong to the caller and be paid', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const body = rest.slice(rest.indexOf('private function owns_paid_order'));
|
||
|
||
assert.match(body, /get_user_order\(get_current_user_id\(\), \$order_number\)/);
|
||
assert.match(body, /ORDER_STATUS_PAID/);
|
||
// 新建时关联订单非法必须拒掉。
|
||
assert.match(rest, /if \(\$order_number !== '' && !\$this->owns_paid_order\(\$order_number\)\) \{\s*\n\s*return new \\WP_Error\('jio_ticket_order'/);
|
||
});
|
||
|
||
test('admin routes all require manage_options', () => {
|
||
const admin = read('src/Ticket/TicketRestAdmin.php');
|
||
const body = admin.slice(admin.indexOf('public function register_routes()'));
|
||
|
||
const calls = body.match(/register_rest_route\([\s\S]*?\n\s*\]\);?/g) || [];
|
||
assert.ok(calls.length >= 20, `expected a full admin surface, found ${calls.length}`);
|
||
for (const call of calls) {
|
||
assert.ok(call.includes("'permission_callback' => [\$this, 'require_admin']"),
|
||
`admin route without require_admin:\n${call.slice(0, 160)}`);
|
||
}
|
||
|
||
assert.match(admin, /current_user_can\('manage_options'\)/);
|
||
// 管理侧命名空间与用户侧隔离。
|
||
assert.match(body, /\$namespace = 'jio\/v1\/admin';/);
|
||
});
|
||
|
||
test('closed tickets refuse replies and cannot be re-closed', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const admin = read('src/Ticket/TicketRestAdmin.php');
|
||
|
||
// 用户侧
|
||
assert.match(rest, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('jio_ticket_closed', __\('工单已关闭,无法回复'/);
|
||
assert.match(rest, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('jio_ticket_closed', __\('工单已关闭,无法催单'/);
|
||
// 管理侧
|
||
assert.match(admin, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('jio_ticket_closed'/);
|
||
|
||
// close_ticket 自身也挡已完结(双保险,避免前端绕过 UI 直接 POST)。
|
||
const status = read('src/Ticket/TicketStatus.php');
|
||
assert.match(status, /if \(TicketStatusService::is_closed\(\(string\) \(\$ticket\['status'\] \?\? ''\)\)\) \{\s*\n\s*return false;/);
|
||
});
|
||
|
||
test('urge has a cooldown driven by settings', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const body = rest.slice(rest.indexOf('public function urge('));
|
||
|
||
// 冷却以工单行 last_urge_time 为准(条件 UPDATE 防并发),不再依赖 transient。
|
||
assert.ok(!/_transient\('jio_ticket_urge_/.test(rest), 'urge cooldown must not use transients');
|
||
assert.match(body, /\$count = \$repo->record_urge\(\$id, TicketSettings::urge_interval\(\) \* MINUTE_IN_SECONDS\);/);
|
||
assert.match(body, /if \(\$count <= 0\) \{\s*\n\s*return new \\WP_Error\('jio_ticket_urge_limit'/);
|
||
|
||
const repo = read('src/Ticket/TicketRepository.php');
|
||
assert.match(repo, /SET urge_count = urge_count \+ 1, last_urge_time = %s/);
|
||
assert.match(repo, /AND \(last_urge_time IS NULL OR last_urge_time <= %s\)/);
|
||
});
|
||
|
||
test('urge writes a system record, notifies admins and fires the mail hook', () => {
|
||
const rest = read('src/Ticket/TicketRest.php');
|
||
const body = rest.slice(rest.indexOf('public function urge('), rest.indexOf('public function close('));
|
||
|
||
assert.match(body, /\$repo->create_reply\(\$id, 'system', get_current_user_id\(\)/);
|
||
assert.match(body, /TicketNotifier::admin_activity\(\$ticket, 'urge'\);/);
|
||
assert.match(body, /do_action\('jio_ticket_urged', \$ticket\);/);
|
||
|
||
const repo = read('src/Ticket/TicketRepository.php');
|
||
assert.match(repo, /if \(!in_array\(\$author_type, \['admin', 'system'\], true\)\) \{/);
|
||
assert.match(repo, /'urge_count' => \(int\) \(\$row\['urge_count'\] \?\? 0\),/);
|
||
});
|
||
|
||
test('quoted replies must belong to the same ticket', () => {
|
||
for (const file of ['src/Ticket/TicketRest.php', 'src/Ticket/TicketRestAdmin.php']) {
|
||
const src = read(file);
|
||
// 跨工单引用与系统记录(催单提示)一律丢弃。
|
||
assert.match(src, /if \(\$quoted === null \|\| \(int\) \$quoted\['ticket_id'\] !== \$id \|\| \$quoted\['author_type'\] === 'system'\) \{\s*\n\s*\$quote_id = 0;/,
|
||
`${file} must drop cross-ticket and system quotes`);
|
||
}
|
||
|
||
const repo = read('src/Ticket/TicketRepository.php');
|
||
// 自引用同样要挡掉,否则时间线会出现循环引用。
|
||
assert.match(repo, /if \(\$quote_id === \$self_id\) \{\s*\n\s*return null;/);
|
||
// 被引用的回复已删除时返回 is_deleted 标记,界面显示占位而不是空白。
|
||
assert.match(repo, /'is_deleted' => true,/);
|
||
});
|