'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,不自建上传链路。 assert.match(rest, /callback' => \[\\AKSRT\\Admin\\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\('aksrt_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 = 'aksrt\/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\('aksrt_ticket_closed', __\('工单已关闭,无法回复'/); assert.match(rest, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_closed', __\('工单已关闭,无法催单'/); // 管理侧 assert.match(admin, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_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(')); assert.match(body, /get_transient\('aksrt_ticket_urge_' \. \$id\)/); assert.match(body, /set_transient\('aksrt_ticket_urge_' \. \$id, time\(\)/); assert.match(body, /TicketSettings::urge_interval\(\) \* MINUTE_IN_SECONDS/); }); 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\) \{\s*\n\s*\$quote_id = 0;/, `${file} must drop cross-ticket 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,/); });