7a6c3c3fa3
普通管理员勾到无权授予的菜单后,ensureGrantable 抛 403,而 createUser/updateUser 都是 @Transactional,整笔回滚——线上表现就是「后台不能保存权限,也不能创建用户」 (2026-09-16 周丽娥账号创建用户与保存 uid=45 权限双双失败,库里查无新用户)。 根因是校验 2026-07-28 就加了,但授权树一直拿全量菜单,两边规则不一致。 - permission-menus 按操作者标记 grantable;授权树据此置灰(不隐藏:授权是整树替换, 隐藏会把超管授予过、操作者自己没有的菜单当取消勾选删掉,与 09-13「权限自己没掉」同类) - ensureGrantable 放行目标已持有的授权,只拦新增,不构成提权 - GlobalExceptionHandler 补业务异常日志:此前普通业务异常一行都不记,本次排查只能靠 反推响应体字节数(nginx body_bytes_sent 含 chunked 开销)才定位到根因
87 lines
4.0 KiB
TypeScript
87 lines
4.0 KiB
TypeScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { readSource } from './helpers.ts'
|
|
import {
|
|
buildMenuOptionTree,
|
|
compactDirectGrantIds,
|
|
parseMenuOptionList,
|
|
parsePermissionMenuItem,
|
|
} from '../src/pages/account/user-menu-auth.ts'
|
|
|
|
// 2026-09-16 线上事故:非超管(普通管理员)在授权树里勾到自己无权授予的菜单后,
|
|
// 后端 ensureGrantable 抛 403 并回滚整笔事务——创建用户与保存权限双双失败,
|
|
// 前端只显示「普通管理员只能分配自己已有的菜单权限」。
|
|
// 修复:菜单列表按操作者标记 grantable,前端把不可授予的节点置灰不可勾。
|
|
//
|
|
// 注意:这里是「置灰」而非「隐藏」。授权保存是整树替换,隐藏会让超管早先授予、
|
|
// 而操作者自己没有的菜单在提交时被当作取消勾选删掉(与 09-13「权限自己没掉」同类)。
|
|
|
|
test('align_user_menu_grantable_false_maps_to_disabled_node', () => {
|
|
const locked = parsePermissionMenuItem(
|
|
{ id: 5, name: '查询ASIN', parent_id: null, sort_order: 1, grantable: false },
|
|
'admin',
|
|
)
|
|
assert.equal(locked?.disabled, true, 'grantable=false → 节点置灰')
|
|
|
|
const allowed = parsePermissionMenuItem(
|
|
{ id: 6, name: '店铺管理', parent_id: null, sort_order: 2, grantable: true },
|
|
'admin',
|
|
)
|
|
assert.equal(allowed?.disabled, false, 'grantable=true → 可勾选')
|
|
|
|
// 菜单管理页等未标记 grantable 的接口必须保持旧行为(全部可勾选)
|
|
const unmarked = parsePermissionMenuItem({ id: 7, name: '菜单权限配置', parent_id: null, sort_order: 3 }, 'admin')
|
|
assert.equal(unmarked?.disabled, false, '缺省 grantable 视为可授予')
|
|
})
|
|
|
|
test('align_user_menu_grantable_survives_tree_build', () => {
|
|
const nodes = parseMenuOptionList(
|
|
[
|
|
{ id: 98, name: '账号与权限', parent_id: null, sort_order: 1, grantable: false },
|
|
{ id: 7, name: '用户管理', parent_id: 98, sort_order: 1, grantable: false },
|
|
{ id: 100, name: '店铺管理', parent_id: null, sort_order: 2, grantable: true },
|
|
],
|
|
'admin',
|
|
)
|
|
const tree = buildMenuOptionTree(nodes)
|
|
const account = tree.find((node) => node.id === 98)
|
|
assert.equal(account?.disabled, true, '分组节点置灰')
|
|
assert.equal(account?.children?.[0]?.disabled, true, '子节点置灰随树保留')
|
|
assert.equal(tree.find((node) => node.id === 100)?.disabled, false, '可授予节点不受影响')
|
|
})
|
|
|
|
test('align_user_menu_grantable_disabled_node_still_compactable', () => {
|
|
// 已持有但无权授予的节点会保持勾选并原样提交,压缩逻辑不能因 disabled 漏掉它
|
|
const tree = buildMenuOptionTree(
|
|
parseMenuOptionList(
|
|
[
|
|
{ id: 98, name: '账号与权限', parent_id: null, sort_order: 1, grantable: false },
|
|
{ id: 7, name: '用户管理', parent_id: 98, sort_order: 1, grantable: false },
|
|
],
|
|
'admin',
|
|
),
|
|
)
|
|
assert.deepEqual(compactDirectGrantIds([98, 7], tree), [98], '父级已勾选时仍压缩掉后代')
|
|
})
|
|
|
|
test('align_user_menu_grantable_wired_end_to_end', () => {
|
|
const tree = readSource('src/pages/account/UserMenuAuthTree.vue')
|
|
assert.match(tree, /disabled: 'disabled'/, 'el-tree 按 disabled 键置灰节点')
|
|
|
|
const vo = readSource(
|
|
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java',
|
|
)
|
|
assert.match(vo, /private Boolean grantable;/, 'VO 暴露 grantable')
|
|
|
|
const controller = readSource(
|
|
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java',
|
|
)
|
|
assert.match(controller, /permissionMenuService\.list\(requireAdmin\(request\), menuType\)/, '列表接口传入操作者')
|
|
|
|
const service = readSource(
|
|
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java',
|
|
)
|
|
assert.match(service, /resolveGrantableMenuIds/, '按操作者计算可授予集')
|
|
assert.match(service, /ensureGrantable\(operator, grantIds, userId\)/, '保存校验传入目标用户以放行既有授权')
|
|
})
|