测试:校验菜单接口权限一致性

This commit is contained in:
焦龙言
2026-07-27 14:59:36 +08:00
parent 9b5f479f0d
commit 33d73ca0ef
3 changed files with 783 additions and 48 deletions
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { lstat, readFile } from 'node:fs/promises'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
@@ -332,6 +332,95 @@ const reportPageContracts = {
}
}
const expectedPagePermissions = [
'master:strain:list',
'master:customer:list',
'master:site:list',
'master:line:list',
'master:warehouse:list',
'master:pallet:list',
'business:inbound:list',
'business:filling:list',
'business:shipment:list',
'business:usage:list',
'business:return:list',
'report:palletinventory:list',
'report:filling:list',
'report:shipment:list',
'report:consumption:list',
'report:return:list',
'report:pallettrace:list'
]
const expectedButtonPermissions = [
'master:strain:query',
'master:strain:add',
'master:strain:edit',
'master:strain:remove',
'master:customer:query',
'master:customer:add',
'master:customer:edit',
'master:customer:remove',
'master:site:query',
'master:site:add',
'master:site:edit',
'master:site:remove',
'master:site:assign',
'master:line:query',
'master:line:add',
'master:line:edit',
'master:line:remove',
'master:warehouse:query',
'master:warehouse:add',
'master:warehouse:edit',
'master:warehouse:remove',
'master:warehouse:assign',
'master:pallet:query',
'master:pallet:add',
'master:pallet:edit',
'master:pallet:remove',
'master:pallet:print',
'business:inbound:query',
'business:inbound:add',
'business:inbound:edit',
'business:inbound:remove',
'business:inbound:confirm',
'business:filling:query',
'business:filling:add',
'business:shipment:query',
'business:shipment:add',
'business:shipment:edit',
'business:shipment:remove',
'business:shipment:confirm',
'business:usage:query',
'business:usage:add',
'business:return:query',
'business:return:request',
'business:return:confirm'
]
const expectedPermissions = [...expectedPagePermissions, ...expectedButtonPermissions].sort()
const expectedPageComponents = {
'master/strain/index': 'src/views/master/strain/index.vue',
'master/customer/index': 'src/views/master/customer/index.vue',
'master/site/index': 'src/views/master/site/index.vue',
'master/line/index': 'src/views/master/line/index.vue',
'master/warehouse/index': 'src/views/master/warehouse/index.vue',
'master/pallet/index': 'src/views/master/pallet/index.vue',
'business/inbound/index': 'src/views/business/inbound/index.vue',
'business/filling/index': 'src/views/business/filling/index.vue',
'business/shipment/index': 'src/views/business/shipment/index.vue',
'business/usage/index': 'src/views/business/usage/index.vue',
'business/return/index': 'src/views/business/return/index.vue',
'report/palletInventory/index': 'src/views/report/palletInventory/index.vue',
'report/filling/index': 'src/views/report/filling/index.vue',
'report/shipment/index': 'src/views/report/shipment/index.vue',
'report/consumption/index': 'src/views/report/consumption/index.vue',
'report/return/index': 'src/views/report/return/index.vue',
'report/palletTrace/index': 'src/views/report/palletTrace/index.vue'
}
const delimiterPairs = { '(': ')', '[': ']', '{': '}' }
function sanitizeSource(source, { allowTemplateInterpolation = false } = {}) {
@@ -428,6 +517,99 @@ function splitTopLevelRanges(structure, separator) {
return ranges
}
function parseStaticPermissionArray(expression) {
const trimmed = expression.trim()
assert.ok(trimmed.startsWith('[') && trimmed.endsWith(']'), '权限门禁必须使用静态字符串数组')
const body = trimmed.slice(1, -1)
const sanitized = sanitizeSource(body, { allowTemplateInterpolation: true })
const permissions = []
for (const [start, end] of splitTopLevelRanges(sanitized.structure, ',')) {
const item = sanitized.code.slice(start, end).trim()
if (!item) continue
const literal = /^(['"])((?:master|business|report):[a-z]+:[a-z]+)\1$/.exec(item)
assert.ok(literal, '权限门禁数组禁止动态表达式或非法字面量')
permissions.push(literal[2])
}
assert.ok(permissions.length > 0, '权限门禁必须使用非空静态字符串数组')
return permissions
}
function extractStaticHasPermiAnd(expression) {
const sanitized = sanitizeSource(expression, { allowTemplateInterpolation: true })
const callPattern = /proxy\.\$auth\.hasPermiAnd\s*\(/g
const permissions = []
let match
while ((match = callPattern.exec(sanitized.structure)) !== null) {
const openParenthesis = match.index + match[0].lastIndexOf('(')
const closeParenthesis = findClosingDelimiter(sanitized.structure, openParenthesis)
const argument = sanitized.code.slice(openParenthesis + 1, closeParenthesis)
permissions.push(...parseStaticPermissionArray(argument))
}
return permissions
}
function stripVueHtmlComments(source) {
const withoutComments = source.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\r\n]/g, ' '))
assert.doesNotMatch(withoutComments, /<!--/, 'Vue 源码包含未闭合 HTML 注释')
return withoutComments
}
function extractGuardedVuePermissions(source) {
const cleanSource = stripVueHtmlComments(source)
const sanitized = sanitizeSource(cleanSource, { allowTemplateInterpolation: true })
const permissions = extractStaticHasPermiAnd(cleanSource)
const directivePattern = /^v-hasPermi\s*=\s*(?:"([^"]*)"|'([^']*)')/
const vIfPattern = /^v-if\s*=\s*(?:"([^"]*)"|'([^']*)')/
for (const token of sanitized.structure.matchAll(/\bv-hasPermi\b/g)) {
const directive = directivePattern.exec(sanitized.code.slice(token.index))
assert.ok(directive, 'v-hasPermi 必须直接绑定静态字符串数组')
permissions.push(...parseStaticPermissionArray(directive[1] ?? directive[2]))
}
for (const token of sanitized.structure.matchAll(/\bv-if\b/g)) {
const attribute = vIfPattern.exec(sanitized.code.slice(token.index))
assert.ok(attribute, 'v-if 必须使用可静态解析的属性值')
permissions.push(...extractStaticHasPermiAnd(attribute[1] ?? attribute[2]))
}
return [...new Set(permissions)].sort()
}
function assertExactPermissionLayers(layers) {
for (const [name, permissions] of Object.entries(layers)) {
assert.deepEqual([...new Set(permissions)].sort(), expectedPermissions, `${name} 权限集合不精确`)
}
}
async function assertPageComponentContract(componentEntries, pageRelativePaths, inspectPath = lstat) {
const viewsRoot = path.resolve(frontendRoot, 'src/views')
assert.equal(componentEntries.length, 17, 'SQL page component 必须正好 17 个')
assert.equal(new Set(componentEntries.map(([component]) => component)).size, 17, 'SQL page component 必须唯一')
assert.equal(pageRelativePaths.length, 17, 'page contract 必须正好 17 个')
assert.equal(new Set(pageRelativePaths).size, 17, 'page contract relativePath 必须唯一')
for (const [component, relativePath] of componentEntries) {
assert.ok(!path.isAbsolute(component), `${component} 不得为绝对路径`)
assert.ok(!component.split('/').includes('..'), `${component} 不得越出 src/views`)
assert.equal(relativePath, `src/views/${component}.vue`, `${component} 与 page contract 路径不一致`)
const absolutePath = path.resolve(frontendRoot, relativePath)
const relativeToViews = path.relative(viewsRoot, absolutePath)
assert.ok(
relativeToViews && relativeToViews !== '..' && !relativeToViews.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeToViews),
`${component} 必须位于 src/views 内`
)
const stats = await inspectPath(absolutePath)
assert.ok(stats.isFile() && !stats.isSymbolicLink(), `${component}.vue 必须为普通文件`)
}
assert.deepEqual(
[...componentEntries].sort(([left], [right]) => left.localeCompare(right)),
Object.entries(expectedPageComponents).sort(([left], [right]) => left.localeCompare(right)),
'SQL page component 与硬编码预期不一致'
)
assert.deepEqual([...pageRelativePaths].sort(), Object.values(expectedPageComponents).sort())
}
function normalizeUrlExpression(code, structure) {
let normalized = ''
for (let index = 0; index < code.length; index += 1) {
@@ -624,6 +806,105 @@ function hasExpectedRequest(expectedRequest) {
)
}
test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async () => {
assert.equal(expectedPagePermissions.length, 17)
assert.equal(expectedButtonPermissions.length, 44)
assert.equal(new Set(expectedPermissions).size, 61)
const relativePaths = [
...Object.values(masterPageContracts),
...Object.values(businessPageContracts),
...Object.values(reportPageContracts)
].map((contract) => contract.relativePath)
assert.equal(relativePaths.length, 17)
const actualPermissions = []
for (const relativePath of relativePaths) {
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
const guarded = extractGuardedVuePermissions(source)
assert.ok(guarded.length > 0, `${relativePath} 缺少真实权限门禁`)
actualPermissions.push(...guarded)
}
assertExactPermissionLayers({ vue: actualPermissions })
})
test('真实权限门禁解析拒绝注释常量、hasPermiOr 和动态数组假阳性', () => {
const staleSource = `
<!-- v-hasPermi="['master:strain:query']" -->
<script setup>const stalePermission = 'master:strain:query'</script>
`
assert.deepEqual(extractGuardedVuePermissions(staleSource), [])
assert.deepEqual(
extractGuardedVuePermissions(
`<button v-if="proxy.$auth.hasPermiOr(['master:strain:query'])">错误门禁</button>`
),
[]
)
assert.throws(
() => extractGuardedVuePermissions('<button v-hasPermi="permissionArray">动态门禁</button>'),
/静态字符串数组/
)
assert.throws(
() => extractGuardedVuePermissions('<button v-if="proxy.$auth.hasPermiAnd(permissionArray)">动态门禁</button>'),
/静态字符串数组/
)
assert.throws(
() => extractGuardedVuePermissions(`<button v-if="proxy.$auth.hasPermiAnd(['master:strain:list', permission])">动态门禁</button>`),
/动态表达式/
)
const missing = expectedPermissions.filter((permission) => permission !== 'master:strain:query')
assert.throws(
() => assertExactPermissionLayers({ mysql: missing, postgresql: missing, controller: missing, vue: missing }),
/权限集合不精确/
)
})
test('17 个 SQL component 正好映射 17 个 page contract 普通文件', async () => {
const componentEntries = Object.entries(expectedPageComponents)
const pageRelativePaths = [
...Object.values(masterPageContracts),
...Object.values(businessPageContracts),
...Object.values(reportPageContracts)
].map((contract) => contract.relativePath)
await assertPageComponentContract(componentEntries, pageRelativePaths)
})
test('component 路径校验拒绝目录、越界、重复和不存在目标', async (t) => {
const validEntries = Object.entries(expectedPageComponents)
const pageRelativePaths = Object.values(expectedPageComponents)
await t.test('目录', async () => {
const directoryStats = { isFile: () => false, isSymbolicLink: () => false }
await assert.rejects(
assertPageComponentContract(validEntries, pageRelativePaths, async () => directoryStats),
/普通文件/
)
})
await t.test('越界', async () => {
const outsideEntries = [...validEntries]
outsideEntries[0] = ['../outside', 'src/views/../outside.vue']
await assert.rejects(assertPageComponentContract(outsideEntries, pageRelativePaths), /src\/views/)
})
await t.test('重复', async () => {
const duplicateEntries = [...validEntries]
duplicateEntries[1] = duplicateEntries[0]
await assert.rejects(assertPageComponentContract(duplicateEntries, pageRelativePaths), /必须唯一/)
})
await t.test('不存在', async () => {
const missingEntries = [...validEntries]
missingEntries[0] = ['master/missing/index', 'src/views/master/missing/index.vue']
const missingError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
await assert.rejects(
assertPageComponentContract(missingEntries, pageRelativePaths, async () => { throw missingError }),
{ code: 'ENOENT' }
)
})
})
test('契约解析器支持 url 与 method 换序及空白格式变化', () => {
const source = `
export function sample(query) {
@@ -771,9 +1052,7 @@ test('六个只读报表页面逐页满足 API、权限、筛选、列和异步
assert.doesNotMatch(source, /@\/api\/(?:master|business)\//)
assert.match(source, new RegExp(`${contract.apiName}\\(listContext\\.query\\)`))
const actualPermissions = [...new Set(
[...source.matchAll(/['"](report:[a-z]+:[a-z]+)['"]/g)].map((match) => match[1])
)]
const actualPermissions = extractGuardedVuePermissions(source)
assert.deepEqual(actualPermissions, [contract.permission], `${resource} 页面只能出现自己的 list 权限`)
assertButtonsUsePermission(source, 'handleQuery', contract.permission)
assertButtonsUsePermission(source, 'resetQuery', contract.permission)
@@ -885,10 +1164,9 @@ test('六个基础资料页面逐页满足 API、权限、分页和安全删除
assert.deepEqual(importedModules, [resource], `${resource} 页面不得跨调其他主数据 API`)
assert.deepEqual(extractImportedNames(source, `@/api/master/${resource}`), contract.apiNames)
const actualPermissions = [...new Set(
[...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))]
.map((match) => match[1].slice(permissionPrefix.length + 1))
)].sort()
const actualPermissions = extractGuardedVuePermissions(source)
.filter((permission) => permission.startsWith(`${permissionPrefix}:`))
.map((permission) => permission.slice(permissionPrefix.length + 1))
assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`)
assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`)
@@ -1058,10 +1336,9 @@ test('五个业务页面逐页满足 API、精确权限、列表和交互基础
assert.doesNotMatch(source, /BarcodeDetector|getUserMedia|camera/i)
assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`)
const actualPermissions = [...new Set(
[...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))]
.map((match) => match[1].slice(permissionPrefix.length + 1))
)].sort()
const actualPermissions = extractGuardedVuePermissions(source)
.filter((permission) => permission.startsWith(`${permissionPrefix}:`))
.map((permission) => permission.slice(permissionPrefix.length + 1))
assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`)
assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`)
assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`)