测试:收紧前端权限与组件契约

This commit is contained in:
焦龙言
2026-07-27 15:36:30 +08:00
parent 6f751486e4
commit 9bdafd3bac
2 changed files with 107 additions and 14 deletions
@@ -311,9 +311,12 @@ def _parse_static_permission_array(expression: str) -> list[str]:
def _extract_static_has_permi_and(expression: str) -> list[str]:
code, structure = _sanitize_vue_source(expression)
if re.search(r'proxy\.\$auth\.hasPermiOr\s*\(', structure):
permission_tokens = list(re.finditer(r'\bhasPermi(?:And|Or)\b', structure))
if any(token.group(0) == 'hasPermiOr' for token in permission_tokens):
raise AssertionError('禁止使用 proxy.$auth.hasPermiOr,权限门禁必须使用约定的静态 AND 语义')
call_tokens = list(re.finditer(r'proxy\.\$auth\.hasPermiAnd\s*\(', structure))
if len(permission_tokens) != len(call_tokens):
raise AssertionError('hasPermiAnd 必须使用 canonical proxy.$auth.hasPermiAnd(static-array) 形式')
call_pattern = re.compile(r'proxy\.\$auth\.hasPermiAnd\s*\(\s*(?P<array>\[[^\[\]]*\])\s*\)')
calls = list(call_pattern.finditer(structure))
if len(calls) != len(call_tokens):
@@ -496,6 +499,24 @@ class ApiPermissionContractTest(unittest.TestCase):
with self.assertRaisesRegex(AssertionError, 'hasPermiOr'):
_extract_guarded_vue_permissions(source)
def test_vue_guard_parser_rejects_noncanonical_permission_calls(self) -> None:
valid_guard = '<button v-hasPermi="[\'master:strain:list\']">合法门禁</button>'
invalid_calls = (
"proxy . $auth . hasPermiOr(['master:strain:query'])",
'proxy?.$auth?.hasPermiAnd(permissionArray)',
"proxy['$auth'].hasPermiOr(['master:strain:query'])",
)
for invalid_call in invalid_calls:
source = f'{valid_guard}<button v-if="{invalid_call}">非约定门禁</button>'
with (
self.subTest(invalid_call=invalid_call),
self.assertRaisesRegex(
AssertionError,
'hasPermi|canonical',
),
):
_extract_guarded_vue_permissions(source)
def test_independent_expected_permissions_reject_four_layers_missing_same_permission(self) -> None:
missing = 'master:strain:query'
mutated = EXPECTED_PERMISSIONS - {missing}
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'
import { lstat, readFile } from 'node:fs/promises'
import { lstat, readFile, realpath } from 'node:fs/promises'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
@@ -523,9 +523,14 @@ function parseStaticPermissionArray(expression) {
const body = trimmed.slice(1, -1)
const sanitized = sanitizeSource(body, { allowTemplateInterpolation: true })
const permissions = []
for (const [start, end] of splitTopLevelRanges(sanitized.structure, ',')) {
const ranges = splitTopLevelRanges(sanitized.structure, ',')
for (const [index, [start, end]] of ranges.entries()) {
const item = sanitized.code.slice(start, end).trim()
if (!item) continue
if (!item) {
if (ranges.length === 1) continue
assert.ok(index === ranges.length - 1, '权限门禁数组禁止稀疏元素,仅允许单个尾逗号')
continue
}
const literal = /^(['"])((?:master|business|report):[a-z]+:[a-z]+)\1$/.exec(item)
assert.ok(literal, '权限门禁数组禁止动态表达式或非法字面量')
permissions.push(literal[2])
@@ -536,15 +541,20 @@ function parseStaticPermissionArray(expression) {
function extractStaticHasPermiAnd(expression) {
const sanitized = sanitizeSource(expression, { allowTemplateInterpolation: true })
assert.doesNotMatch(
sanitized.structure,
/proxy\.\$auth\.hasPermiOr\s*\(/,
const permissionTokens = [...sanitized.structure.matchAll(/\bhasPermi(?:And|Or)\b/g)]
assert.ok(
!permissionTokens.some((token) => token[0] === 'hasPermiOr'),
'禁止使用 proxy.$auth.hasPermiOr,权限门禁必须使用约定的静态 AND 语义'
)
const callPattern = /proxy\.\$auth\.hasPermiAnd\s*\(/g
const canonicalCalls = [...sanitized.structure.matchAll(callPattern)]
assert.equal(
canonicalCalls.length,
permissionTokens.length,
'hasPermiAnd 必须使用 canonical proxy.$auth.hasPermiAnd(static-array) 形式'
)
const permissions = []
let match
while ((match = callPattern.exec(sanitized.structure)) !== null) {
for (const match of canonicalCalls) {
const openParenthesis = match.index + match[0].lastIndexOf('(')
const closeParenthesis = findClosingDelimiter(sanitized.structure, openParenthesis)
const argument = sanitized.code.slice(openParenthesis + 1, closeParenthesis)
@@ -586,10 +596,16 @@ function assertExactPermissionLayers(layers) {
}
}
async function assertPageComponentContract(componentEntries, pageRelativePaths, inspectPath = lstat) {
async function assertPageComponentContract(
componentEntries,
pageRelativePaths,
inspectPath = lstat,
resolvePath = realpath
) {
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 必须唯一')
const resolvedViewsRoot = await resolvePath(viewsRoot)
assert.equal(componentEntries.length, 17, '固定 component 映射必须正好 17 个')
assert.equal(new Set(componentEntries.map(([component]) => component)).size, 17, '固定 component 映射必须唯一')
assert.equal(pageRelativePaths.length, 17, 'page contract 必须正好 17 个')
assert.equal(new Set(pageRelativePaths).size, 17, 'page contract relativePath 必须唯一')
@@ -605,12 +621,19 @@ async function assertPageComponentContract(componentEntries, pageRelativePaths,
)
const stats = await inspectPath(absolutePath)
assert.ok(stats.isFile() && !stats.isSymbolicLink(), `${component}.vue 必须为普通文件`)
const resolvedPath = await resolvePath(absolutePath)
const relativeToResolvedViews = path.relative(resolvedViewsRoot, resolvedPath)
assert.ok(
relativeToResolvedViews && relativeToResolvedViews !== '..' &&
!relativeToResolvedViews.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeToResolvedViews),
`${component} 真实路径必须位于 src/views 内`
)
}
assert.deepEqual(
[...componentEntries].sort(([left], [right]) => left.localeCompare(right)),
Object.entries(expectedPageComponents).sort(([left], [right]) => left.localeCompare(right)),
'SQL page component 与硬编码预期不一致'
'固定 component 映射与硬编码预期不一致'
)
assert.deepEqual([...pageRelativePaths].sort(), Object.values(expectedPageComponents).sort())
}
@@ -874,7 +897,39 @@ test('合法门禁混入额外 hasPermiOr 时直接 fail-closed', () => {
assert.throws(() => extractGuardedVuePermissions(source), /hasPermiOr/)
})
test('17 个 SQL component 正好映射 17 个 page contract 普通文件', async () => {
test('完整合法门禁旁的非 canonical 权限调用必须 fail-closed', () => {
const validGuard = `<button v-hasPermi="['master:strain:list']">合法门禁</button>`
const invalidCalls = [
`proxy . $auth . hasPermiOr(['master:strain:query'])`,
'proxy?.$auth?.hasPermiAnd(permissionArray)',
`proxy['$auth'].hasPermiOr(['master:strain:query'])`
]
for (const invalidCall of invalidCalls) {
const source = `${validGuard}<button v-if="${invalidCall}">非约定门禁</button>`
assert.throws(() => extractGuardedVuePermissions(source), /hasPermi|canonical/)
}
})
test('静态权限数组拒绝稀疏元素且仅允许单个尾逗号', () => {
const sparseArrays = [
`[, 'master:strain:list']`,
`['master:strain:list',, 'master:strain:query']`,
`['master:strain:list',,]`
]
for (const permissions of sparseArrays) {
assert.throws(
() => extractGuardedVuePermissions(`<button v-hasPermi="${permissions}">稀疏门禁</button>`),
/稀疏/
)
}
assert.deepEqual(
extractGuardedVuePermissions(`<button v-hasPermi="['master:strain:list',]">尾逗号</button>`),
['master:strain:list']
)
})
test('17 个固定 component 映射正好对应 17 个 page contract 普通文件', async () => {
const componentEntries = Object.entries(expectedPageComponents)
const pageRelativePaths = [
...Object.values(masterPageContracts),
@@ -903,6 +958,23 @@ test('component 路径校验拒绝目录、越界、重复和不存在目标', a
await assert.rejects(assertPageComponentContract(outsideEntries, pageRelativePaths), /src\/views/)
})
await t.test('中间符号链接解析到 views 外', async () => {
const sourceViewsRoot = path.resolve(frontendRoot, 'src/views')
const resolvedViewsRoot = path.resolve('/resolved/views')
const resolveOutside = async (absolutePath) => {
if (absolutePath === sourceViewsRoot) return resolvedViewsRoot
const relativePath = path.relative(sourceViewsRoot, absolutePath)
if (relativePath === 'master/strain/index.vue') {
return path.resolve('/resolved/outside/index.vue')
}
return path.resolve(resolvedViewsRoot, relativePath)
}
await assert.rejects(
assertPageComponentContract(validEntries, pageRelativePaths, lstat, resolveOutside),
/真实路径.*src\/views/
)
})
await t.test('重复', async () => {
const duplicateEntries = [...validEntries]
duplicateEntries[1] = duplicateEntries[0]