Files
wordpress-theme-aksrt/tests/widget-system/ticket-permission-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

166 lines
8.2 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';
/**
* 工单 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,/);
});