测试:校验菜单接口权限一致性
This commit is contained in:
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user