测试:收紧前端权限与组件契约
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]:
|
def _extract_static_has_permi_and(expression: str) -> list[str]:
|
||||||
code, structure = _sanitize_vue_source(expression)
|
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 语义')
|
raise AssertionError('禁止使用 proxy.$auth.hasPermiOr,权限门禁必须使用约定的静态 AND 语义')
|
||||||
call_tokens = list(re.finditer(r'proxy\.\$auth\.hasPermiAnd\s*\(', structure))
|
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*\)')
|
call_pattern = re.compile(r'proxy\.\$auth\.hasPermiAnd\s*\(\s*(?P<array>\[[^\[\]]*\])\s*\)')
|
||||||
calls = list(call_pattern.finditer(structure))
|
calls = list(call_pattern.finditer(structure))
|
||||||
if len(calls) != len(call_tokens):
|
if len(calls) != len(call_tokens):
|
||||||
@@ -496,6 +499,24 @@ class ApiPermissionContractTest(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(AssertionError, 'hasPermiOr'):
|
with self.assertRaisesRegex(AssertionError, 'hasPermiOr'):
|
||||||
_extract_guarded_vue_permissions(source)
|
_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:
|
def test_independent_expected_permissions_reject_four_layers_missing_same_permission(self) -> None:
|
||||||
missing = 'master:strain:query'
|
missing = 'master:strain:query'
|
||||||
mutated = EXPECTED_PERMISSIONS - {missing}
|
mutated = EXPECTED_PERMISSIONS - {missing}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from 'node:assert/strict'
|
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 { test } from 'node:test'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
@@ -523,9 +523,14 @@ function parseStaticPermissionArray(expression) {
|
|||||||
const body = trimmed.slice(1, -1)
|
const body = trimmed.slice(1, -1)
|
||||||
const sanitized = sanitizeSource(body, { allowTemplateInterpolation: true })
|
const sanitized = sanitizeSource(body, { allowTemplateInterpolation: true })
|
||||||
const permissions = []
|
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()
|
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)
|
const literal = /^(['"])((?:master|business|report):[a-z]+:[a-z]+)\1$/.exec(item)
|
||||||
assert.ok(literal, '权限门禁数组禁止动态表达式或非法字面量')
|
assert.ok(literal, '权限门禁数组禁止动态表达式或非法字面量')
|
||||||
permissions.push(literal[2])
|
permissions.push(literal[2])
|
||||||
@@ -536,15 +541,20 @@ function parseStaticPermissionArray(expression) {
|
|||||||
|
|
||||||
function extractStaticHasPermiAnd(expression) {
|
function extractStaticHasPermiAnd(expression) {
|
||||||
const sanitized = sanitizeSource(expression, { allowTemplateInterpolation: true })
|
const sanitized = sanitizeSource(expression, { allowTemplateInterpolation: true })
|
||||||
assert.doesNotMatch(
|
const permissionTokens = [...sanitized.structure.matchAll(/\bhasPermi(?:And|Or)\b/g)]
|
||||||
sanitized.structure,
|
assert.ok(
|
||||||
/proxy\.\$auth\.hasPermiOr\s*\(/,
|
!permissionTokens.some((token) => token[0] === 'hasPermiOr'),
|
||||||
'禁止使用 proxy.$auth.hasPermiOr,权限门禁必须使用约定的静态 AND 语义'
|
'禁止使用 proxy.$auth.hasPermiOr,权限门禁必须使用约定的静态 AND 语义'
|
||||||
)
|
)
|
||||||
const callPattern = /proxy\.\$auth\.hasPermiAnd\s*\(/g
|
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 = []
|
const permissions = []
|
||||||
let match
|
for (const match of canonicalCalls) {
|
||||||
while ((match = callPattern.exec(sanitized.structure)) !== null) {
|
|
||||||
const openParenthesis = match.index + match[0].lastIndexOf('(')
|
const openParenthesis = match.index + match[0].lastIndexOf('(')
|
||||||
const closeParenthesis = findClosingDelimiter(sanitized.structure, openParenthesis)
|
const closeParenthesis = findClosingDelimiter(sanitized.structure, openParenthesis)
|
||||||
const argument = sanitized.code.slice(openParenthesis + 1, closeParenthesis)
|
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')
|
const viewsRoot = path.resolve(frontendRoot, 'src/views')
|
||||||
assert.equal(componentEntries.length, 17, 'SQL page component 必须正好 17 个')
|
const resolvedViewsRoot = await resolvePath(viewsRoot)
|
||||||
assert.equal(new Set(componentEntries.map(([component]) => component)).size, 17, 'SQL page component 必须唯一')
|
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(pageRelativePaths.length, 17, 'page contract 必须正好 17 个')
|
||||||
assert.equal(new Set(pageRelativePaths).size, 17, 'page contract relativePath 必须唯一')
|
assert.equal(new Set(pageRelativePaths).size, 17, 'page contract relativePath 必须唯一')
|
||||||
|
|
||||||
@@ -605,12 +621,19 @@ async function assertPageComponentContract(componentEntries, pageRelativePaths,
|
|||||||
)
|
)
|
||||||
const stats = await inspectPath(absolutePath)
|
const stats = await inspectPath(absolutePath)
|
||||||
assert.ok(stats.isFile() && !stats.isSymbolicLink(), `${component}.vue 必须为普通文件`)
|
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(
|
assert.deepEqual(
|
||||||
[...componentEntries].sort(([left], [right]) => left.localeCompare(right)),
|
[...componentEntries].sort(([left], [right]) => left.localeCompare(right)),
|
||||||
Object.entries(expectedPageComponents).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())
|
assert.deepEqual([...pageRelativePaths].sort(), Object.values(expectedPageComponents).sort())
|
||||||
}
|
}
|
||||||
@@ -874,7 +897,39 @@ test('合法门禁混入额外 hasPermiOr 时直接 fail-closed', () => {
|
|||||||
assert.throws(() => extractGuardedVuePermissions(source), /hasPermiOr/)
|
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 componentEntries = Object.entries(expectedPageComponents)
|
||||||
const pageRelativePaths = [
|
const pageRelativePaths = [
|
||||||
...Object.values(masterPageContracts),
|
...Object.values(masterPageContracts),
|
||||||
@@ -903,6 +958,23 @@ test('component 路径校验拒绝目录、越界、重复和不存在目标', a
|
|||||||
await assert.rejects(assertPageComponentContract(outsideEntries, pageRelativePaths), /src\/views/)
|
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 () => {
|
await t.test('重复', async () => {
|
||||||
const duplicateEntries = [...validEntries]
|
const duplicateEntries = [...validEntries]
|
||||||
duplicateEntries[1] = duplicateEntries[0]
|
duplicateEntries[1] = duplicateEntries[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user