Files
wordpress-theme-aksrt/tests/widget-system/ticket-permission-contract.test.cjs
T
kate522andClaude Opus 5 d32bdc0581 fix(ticket): register the ticket schema inside the users sections array
/users/ticket loaded in the SPA but the form never did: the console showed
"未知的设置分组: ticket" from /settings/schema/users/ticket. The ticket section
was composed one array level too high in SchemaUsers::build() — as a sibling of
'sections' rather than inside it — so SchemaProvider::get_sections(), which reads
only $schema['sections'], never saw it and the REST handler 404'd.

Every other axis was wired correctly: the route, the menu entry, users/ticket.js
and SchemaUsersTicket itself. That is why only this page failed while sibling
/users/* pages worked. Move the entry inside sections.

The contract test could not catch this because it asserted only that the compose
call appears somewhere in the file. Replace it with a bracket-balanced scan that
locates the sections array bounds and asserts the compose call falls inside, so
nesting mistakes are visible.

Also update the two ticket contract tests that still read the trait from
src/Ticket/TicketRepository-traits/, its pre-flattening path. They assert on the
trait's contents, so they were silently skipping the very file the earlier fatal
was about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-26 14:16:25 +08:00

145 lines
6.8 KiB
JavaScript
Raw 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,不自建上传链路。
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,/);
});