diff --git a/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py index 3e95460..19d0778 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_api_permission_contract.py @@ -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\[[^\[\]]*\])\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 = '' + 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}' + 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} diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index e9a7e07..5499059 100644 --- a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs +++ b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs @@ -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 = `` + 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}` + 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(``), + /稀疏/ + ) + } + assert.deepEqual( + extractGuardedVuePermissions(``), + ['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]