测试:收紧前端权限与组件契约
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user