测试:校验菜单接口权限一致性
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import ast
|
import ast
|
||||||
import re
|
import re
|
||||||
import unittest
|
import unittest
|
||||||
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
@@ -9,6 +10,7 @@ from common.aspect.interface_auth import CheckUserInterfaceAuth
|
|||||||
from server import create_app
|
from server import create_app
|
||||||
|
|
||||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
FRONTEND_ROOT = BACKEND_ROOT.parent / 'shierfeng-fastapi-frontend'
|
||||||
CONTROLLER_NAMES = (
|
CONTROLLER_NAMES = (
|
||||||
'strain',
|
'strain',
|
||||||
'customer',
|
'customer',
|
||||||
@@ -31,28 +33,96 @@ TARGET_PREFIXES = (
|
|||||||
ROUTE_HTTP_METHODS = frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE'})
|
ROUTE_HTTP_METHODS = frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE'})
|
||||||
WRITE_METHODS = frozenset({'DELETE', 'PATCH', 'POST', 'PUT'})
|
WRITE_METHODS = frozenset({'DELETE', 'PATCH', 'POST', 'PUT'})
|
||||||
|
|
||||||
EXPECTED_PERMISSION_ACTIONS = {
|
EXPECTED_PAGE_PERMISSIONS = frozenset(
|
||||||
'master:strain': ('list', 'query', 'add', 'edit', 'remove'),
|
{
|
||||||
'master:customer': ('list', 'query', 'add', 'edit', 'remove'),
|
'master:strain:list',
|
||||||
'master:site': ('list', 'query', 'add', 'edit', 'remove', 'assign'),
|
'master:customer:list',
|
||||||
'master:line': ('list', 'query', 'add', 'edit', 'remove'),
|
'master:site:list',
|
||||||
'master:warehouse': ('list', 'query', 'add', 'edit', 'remove', 'assign'),
|
'master:line:list',
|
||||||
'master:pallet': ('list', 'query', 'add', 'edit', 'remove', 'print'),
|
'master:warehouse:list',
|
||||||
'business:inbound': ('list', 'query', 'add', 'edit', 'remove', 'confirm'),
|
'master:pallet:list',
|
||||||
'business:filling': ('list', 'query', 'add'),
|
'business:inbound:list',
|
||||||
'business:shipment': ('list', 'query', 'add', 'edit', 'remove', 'confirm'),
|
'business:filling:list',
|
||||||
'business:usage': ('list', 'query', 'add'),
|
'business:shipment:list',
|
||||||
'business:return': ('list', 'query', 'request', 'confirm'),
|
'business:usage:list',
|
||||||
'report:palletinventory': ('list',),
|
'business:return:list',
|
||||||
'report:filling': ('list',),
|
'report:palletinventory:list',
|
||||||
'report:shipment': ('list',),
|
'report:filling:list',
|
||||||
'report:consumption': ('list',),
|
'report:shipment:list',
|
||||||
'report:return': ('list',),
|
'report:consumption:list',
|
||||||
'report:pallettrace': ('list',),
|
'report:return:list',
|
||||||
}
|
'report:pallettrace:list',
|
||||||
EXPECTED_PERMISSIONS = frozenset(
|
}
|
||||||
f'{resource}:{action}' for resource, actions in EXPECTED_PERMISSION_ACTIONS.items() for action in actions
|
|
||||||
)
|
)
|
||||||
|
EXPECTED_BUTTON_PERMISSIONS = frozenset(
|
||||||
|
{
|
||||||
|
'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',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
|
||||||
|
VUE_PAGE_PATHS = (
|
||||||
|
'src/views/master/strain/index.vue',
|
||||||
|
'src/views/master/customer/index.vue',
|
||||||
|
'src/views/master/site/index.vue',
|
||||||
|
'src/views/master/line/index.vue',
|
||||||
|
'src/views/master/warehouse/index.vue',
|
||||||
|
'src/views/master/pallet/index.vue',
|
||||||
|
'src/views/business/inbound/index.vue',
|
||||||
|
'src/views/business/filling/index.vue',
|
||||||
|
'src/views/business/shipment/index.vue',
|
||||||
|
'src/views/business/usage/index.vue',
|
||||||
|
'src/views/business/return/index.vue',
|
||||||
|
'src/views/report/palletInventory/index.vue',
|
||||||
|
'src/views/report/filling/index.vue',
|
||||||
|
'src/views/report/shipment/index.vue',
|
||||||
|
'src/views/report/consumption/index.vue',
|
||||||
|
'src/views/report/return/index.vue',
|
||||||
|
'src/views/report/palletTrace/index.vue',
|
||||||
|
)
|
||||||
|
PERMISSION_LITERAL_PATTERN = re.compile(r'(?:master|business|report):[a-z]+:[a-z]+')
|
||||||
|
|
||||||
|
|
||||||
def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
|
def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
|
||||||
@@ -169,6 +239,119 @@ def _auth_dependencies(route: APIRoute) -> list[CheckUserInterfaceAuth]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_controller_permission_literals(test_case: unittest.TestCase, literals: list[str]) -> None:
|
||||||
|
expected = Counter(EXPECTED_ROUTE_PERMISSIONS.values())
|
||||||
|
test_case.assertEqual(74, expected.total())
|
||||||
|
test_case.assertEqual(expected, Counter(literals))
|
||||||
|
test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(expected))
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_vue_source(source: str) -> tuple[str, str]: # noqa: PLR0912
|
||||||
|
code = list(source)
|
||||||
|
structure = list(source)
|
||||||
|
index = 0
|
||||||
|
while index < len(source):
|
||||||
|
if source.startswith('<!--', index):
|
||||||
|
end = source.find('-->', index + 4)
|
||||||
|
if end == -1:
|
||||||
|
raise AssertionError('Vue 源码包含未闭合 HTML 注释')
|
||||||
|
end += 3
|
||||||
|
for offset in range(index, end):
|
||||||
|
if source[offset] not in '\r\n':
|
||||||
|
code[offset] = structure[offset] = ' '
|
||||||
|
index = end
|
||||||
|
continue
|
||||||
|
|
||||||
|
quote = source[index]
|
||||||
|
if quote in '\'"`':
|
||||||
|
end = index + 1
|
||||||
|
while end < len(source) and source[end] != quote:
|
||||||
|
end += 2 if source[end] == '\\' else 1
|
||||||
|
if end >= len(source):
|
||||||
|
raise AssertionError('Vue 源码包含未闭合字符串')
|
||||||
|
for offset in range(index, end + 1):
|
||||||
|
if source[offset] not in '\r\n':
|
||||||
|
structure[offset] = ' '
|
||||||
|
index = end + 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_line_comment = source.startswith('//', index)
|
||||||
|
is_block_comment = source.startswith('/*', index)
|
||||||
|
if is_line_comment or is_block_comment:
|
||||||
|
marker = '\n' if is_line_comment else '*/'
|
||||||
|
end = source.find(marker, index + 2)
|
||||||
|
if end == -1 and is_block_comment:
|
||||||
|
raise AssertionError('Vue 源码包含未闭合块注释')
|
||||||
|
end = len(source) if end == -1 else end + (0 if is_line_comment else 2)
|
||||||
|
for offset in range(index, end):
|
||||||
|
if source[offset] not in '\r\n':
|
||||||
|
code[offset] = structure[offset] = ' '
|
||||||
|
index = end
|
||||||
|
continue
|
||||||
|
index += 1
|
||||||
|
return ''.join(code), ''.join(structure)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_static_permission_array(expression: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
parsed = ast.parse(expression.strip(), mode='eval').body
|
||||||
|
except SyntaxError as exc:
|
||||||
|
raise AssertionError('权限门禁必须使用静态字符串数组') from exc
|
||||||
|
if not isinstance(parsed, ast.List) or not parsed.elts:
|
||||||
|
raise AssertionError('权限门禁必须使用非空静态字符串数组')
|
||||||
|
permissions = []
|
||||||
|
for element in parsed.elts:
|
||||||
|
if not isinstance(element, ast.Constant) or not isinstance(element.value, str):
|
||||||
|
raise AssertionError('权限门禁数组禁止动态表达式')
|
||||||
|
if PERMISSION_LITERAL_PATTERN.fullmatch(element.value) is None:
|
||||||
|
raise AssertionError(f'非法权限字面量: {element.value}')
|
||||||
|
permissions.append(element.value)
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_static_has_permi_and(expression: str) -> list[str]:
|
||||||
|
code, structure = _sanitize_vue_source(expression)
|
||||||
|
call_tokens = list(re.finditer(r'proxy\.\$auth\.hasPermiAnd\s*\(', structure))
|
||||||
|
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):
|
||||||
|
raise AssertionError('hasPermiAnd 必须直接传入静态字符串数组')
|
||||||
|
|
||||||
|
permissions = []
|
||||||
|
for call in calls:
|
||||||
|
start, end = call.span('array')
|
||||||
|
permissions.extend(_parse_static_permission_array(code[start:end]))
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_guarded_vue_permissions(source: str) -> frozenset[str]:
|
||||||
|
code, structure = _sanitize_vue_source(source)
|
||||||
|
permissions = []
|
||||||
|
|
||||||
|
directive_tokens = list(re.finditer(r'\bv-hasPermi\b', structure))
|
||||||
|
directive_pattern = re.compile(r"""v-hasPermi\s*=\s*(?:"(?P<double>[^"]*)"|'(?P<single>[^']*)')""")
|
||||||
|
for token in directive_tokens:
|
||||||
|
match = directive_pattern.match(code, token.start())
|
||||||
|
if match is None:
|
||||||
|
raise AssertionError('v-hasPermi 必须直接绑定静态字符串数组')
|
||||||
|
permissions.extend(_parse_static_permission_array(match.group('double') or match.group('single')))
|
||||||
|
|
||||||
|
permissions.extend(_extract_static_has_permi_and(source))
|
||||||
|
v_if_pattern = re.compile(r"""v-if\s*=\s*(?:"(?P<double>[^"]*)"|'(?P<single>[^']*)')""")
|
||||||
|
for token in re.finditer(r'\bv-if\b', structure):
|
||||||
|
match = v_if_pattern.match(code, token.start())
|
||||||
|
if match is None:
|
||||||
|
raise AssertionError('v-if 必须使用可静态解析的属性值')
|
||||||
|
expression = match.group('double') or match.group('single')
|
||||||
|
permissions.extend(_extract_static_has_permi_and(expression))
|
||||||
|
return frozenset(permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_exact_permission_layers(test_case: unittest.TestCase, layers: dict[str, set | frozenset]) -> None:
|
||||||
|
for name, actual in layers.items():
|
||||||
|
test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(actual), name)
|
||||||
|
|
||||||
|
|
||||||
class ApiPermissionContractTest(unittest.TestCase):
|
class ApiPermissionContractTest(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls) -> None:
|
def setUpClass(cls) -> None:
|
||||||
@@ -246,13 +429,76 @@ class ApiPermissionContractTest(unittest.TestCase):
|
|||||||
literals.append(node.args[0].value)
|
literals.append(node.args[0].value)
|
||||||
|
|
||||||
self.assertEqual([], malformed_calls)
|
self.assertEqual([], malformed_calls)
|
||||||
|
self.assertEqual(17, len(EXPECTED_PAGE_PERMISSIONS))
|
||||||
|
self.assertEqual(44, len(EXPECTED_BUTTON_PERMISSIONS))
|
||||||
|
self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS)
|
||||||
self.assertEqual(61, len(EXPECTED_PERMISSIONS))
|
self.assertEqual(61, len(EXPECTED_PERMISSIONS))
|
||||||
self.assertEqual(EXPECTED_PERMISSIONS, frozenset(literals))
|
_assert_controller_permission_literals(self, literals)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{'add', 'assign', 'confirm', 'edit', 'list', 'print', 'query', 'remove', 'request'},
|
{'add', 'assign', 'confirm', 'edit', 'list', 'print', 'query', 'remove', 'request'},
|
||||||
{permission.rsplit(':', maxsplit=1)[1] for permission in literals},
|
{permission.rsplit(':', maxsplit=1)[1] for permission in literals},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None:
|
||||||
|
literals = list(EXPECTED_ROUTE_PERMISSIONS.values())
|
||||||
|
self.assertEqual(74, len(literals))
|
||||||
|
literals.append(literals[0])
|
||||||
|
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
_assert_controller_permission_literals(self, literals)
|
||||||
|
|
||||||
|
def test_vue_guard_parser_ignores_comments_and_unrelated_constants(self) -> None:
|
||||||
|
source = """
|
||||||
|
<!-- v-hasPermi="['master:strain:query']" -->
|
||||||
|
<script setup>
|
||||||
|
const staleExample = 'master:strain:query'
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.assertEqual(frozenset(), _extract_guarded_vue_permissions(source))
|
||||||
|
|
||||||
|
def test_vue_pages_have_exact_guarded_permission_contract(self) -> None:
|
||||||
|
actual_permissions = set()
|
||||||
|
self.assertEqual(17, len(VUE_PAGE_PATHS))
|
||||||
|
self.assertEqual(17, len(set(VUE_PAGE_PATHS)))
|
||||||
|
for relative_path in VUE_PAGE_PATHS:
|
||||||
|
source = (FRONTEND_ROOT / relative_path).read_text(encoding='utf-8')
|
||||||
|
guarded = _extract_guarded_vue_permissions(source)
|
||||||
|
self.assertTrue(guarded, relative_path)
|
||||||
|
actual_permissions.update(guarded)
|
||||||
|
|
||||||
|
_assert_exact_permission_layers(self, {'vue': actual_permissions})
|
||||||
|
|
||||||
|
def test_vue_guard_parser_rejects_dynamic_arrays_and_has_permi_or(self) -> None:
|
||||||
|
dynamic_sources = (
|
||||||
|
'<button v-hasPermi="permissionArray">动态权限</button>',
|
||||||
|
'<button v-if="proxy.$auth.hasPermiAnd(permissionArray)">动态权限</button>',
|
||||||
|
'<button v-if="proxy.$auth.hasPermiAnd([\'master:strain:list\', permission])">动态权限</button>',
|
||||||
|
)
|
||||||
|
for source in dynamic_sources:
|
||||||
|
with self.subTest(source=source), self.assertRaises(AssertionError):
|
||||||
|
_extract_guarded_vue_permissions(source)
|
||||||
|
|
||||||
|
guarded = _extract_guarded_vue_permissions(
|
||||||
|
'<button v-if="proxy.$auth.hasPermiOr([\'master:strain:query\'])">错误门禁</button>'
|
||||||
|
)
|
||||||
|
self.assertEqual(frozenset(), guarded)
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
_assert_exact_permission_layers(self, {'vue': guarded})
|
||||||
|
|
||||||
|
def test_independent_expected_permissions_reject_four_layers_missing_same_permission(self) -> None:
|
||||||
|
missing = 'master:strain:query'
|
||||||
|
mutated = EXPECTED_PERMISSIONS - {missing}
|
||||||
|
layers = {
|
||||||
|
'mysql': mutated,
|
||||||
|
'postgresql': mutated,
|
||||||
|
'controller': mutated,
|
||||||
|
'vue': mutated,
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
_assert_exact_permission_layers(self, layers)
|
||||||
|
|
||||||
def test_static_scan_and_option_routes_precede_dynamic_details(self) -> None:
|
def test_static_scan_and_option_routes_precede_dynamic_details(self) -> None:
|
||||||
route_order = {
|
route_order = {
|
||||||
route.path: index
|
route.path: index
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import re
|
import re
|
||||||
import unittest
|
import unittest
|
||||||
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql'
|
SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql'
|
||||||
INITIALIZERS = ('shierfeng-fastapi.sql', 'shierfeng-fastapi-pg.sql')
|
INITIALIZERS = ('shierfeng-fastapi.sql', 'shierfeng-fastapi-pg.sql')
|
||||||
PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
|
PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
|
||||||
SYS_MENU_VALUE_COUNT = 20
|
SYS_MENU_VALUE_COUNT = 20
|
||||||
|
SYS_MENU_PERMS_INDEX = 13
|
||||||
REMOVED_AI_DICT_CODES = set(range(33, 70))
|
REMOVED_AI_DICT_CODES = set(range(33, 70))
|
||||||
|
|
||||||
EXPECTED_TABLES = {
|
EXPECTED_TABLES = {
|
||||||
@@ -408,6 +410,161 @@ EXPECTED_BUTTONS = {
|
|||||||
line.split() for line in BUTTON_SPECS.splitlines() if line.strip()
|
line.split() for line in BUTTON_SPECS.splitlines() if line.strip()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
EXPECTED_PAGE_PERMISSIONS = frozenset(
|
||||||
|
[
|
||||||
|
'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',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
EXPECTED_BUTTON_PERMISSIONS = frozenset(
|
||||||
|
[
|
||||||
|
'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',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
|
||||||
|
BUSINESS_MENU_SPECS = """
|
||||||
|
121|1|基础资料维护|businessMaster||10||<NULL>
|
||||||
|
122|1|业务管理|businessOperation||11||<NULL>
|
||||||
|
123|1|报表查询|businessReport||12||<NULL>
|
||||||
|
502|121|菌种资料|strain||1|master:strain:list|master/strain/index
|
||||||
|
503|121|客户资料|customer||2|master:customer:list|master/customer/index
|
||||||
|
504|121|客户现场|site||3|master:site:list|master/site/index
|
||||||
|
505|121|产线资料|line||4|master:line:list|master/line/index
|
||||||
|
506|121|仓库资料|warehouse||5|master:warehouse:list|master/warehouse/index
|
||||||
|
507|121|托盘资料|pallet||6|master:pallet:list|master/pallet/index
|
||||||
|
508|122|仓库入库|inbound||1|business:inbound:list|business/inbound/index
|
||||||
|
509|122|工厂装填|filling||2|business:filling:list|business/filling/index
|
||||||
|
510|122|发货出库|shipment||3|business:shipment:list|business/shipment/index
|
||||||
|
511|122|客户现场使用|usage||4|business:usage:list|business/usage/index
|
||||||
|
512|122|退回仓库|return||5|business:return:list|business/return/index
|
||||||
|
513|123|托盘库存|palletInventory||1|report:palletinventory:list|report/palletInventory/index
|
||||||
|
514|123|装填记录|reportFilling||2|report:filling:list|report/filling/index
|
||||||
|
515|123|发货出库|reportShipment||3|report:shipment:list|report/shipment/index
|
||||||
|
516|123|产线消耗|consumption||4|report:consumption:list|report/consumption/index
|
||||||
|
517|123|退回仓库|reportReturn||5|report:return:list|report/return/index
|
||||||
|
518|123|托盘追溯|palletTrace||6|report:pallettrace:list|report/palletTrace/index
|
||||||
|
1065|502|菌种查询|||1|master:strain:query|
|
||||||
|
1066|502|菌种新增|||2|master:strain:add|
|
||||||
|
1067|502|菌种修改|||3|master:strain:edit|
|
||||||
|
1068|502|菌种删除|||4|master:strain:remove|
|
||||||
|
1069|503|客户查询|||1|master:customer:query|
|
||||||
|
1070|503|客户新增|||2|master:customer:add|
|
||||||
|
1071|503|客户修改|||3|master:customer:edit|
|
||||||
|
1072|503|客户删除|||4|master:customer:remove|
|
||||||
|
1073|504|现场查询|||1|master:site:query|
|
||||||
|
1074|504|现场新增|||2|master:site:add|
|
||||||
|
1075|504|现场修改|||3|master:site:edit|
|
||||||
|
1076|504|现场删除|||4|master:site:remove|
|
||||||
|
1077|504|分配用户|||5|master:site:assign|
|
||||||
|
1078|505|产线查询|||1|master:line:query|
|
||||||
|
1079|505|产线新增|||2|master:line:add|
|
||||||
|
1080|505|产线修改|||3|master:line:edit|
|
||||||
|
1081|505|产线删除|||4|master:line:remove|
|
||||||
|
1082|506|仓库查询|||1|master:warehouse:query|
|
||||||
|
1083|506|仓库新增|||2|master:warehouse:add|
|
||||||
|
1084|506|仓库修改|||3|master:warehouse:edit|
|
||||||
|
1085|506|仓库删除|||4|master:warehouse:remove|
|
||||||
|
1086|506|分配用户|||5|master:warehouse:assign|
|
||||||
|
1087|507|托盘查询|||1|master:pallet:query|
|
||||||
|
1088|507|托盘新增|||2|master:pallet:add|
|
||||||
|
1089|507|托盘修改|||3|master:pallet:edit|
|
||||||
|
1090|507|托盘删除|||4|master:pallet:remove|
|
||||||
|
1091|507|托盘打印|||5|master:pallet:print|
|
||||||
|
1092|508|入库查询|||1|business:inbound:query|
|
||||||
|
1093|508|入库新增|||2|business:inbound:add|
|
||||||
|
1094|508|入库修改|||3|business:inbound:edit|
|
||||||
|
1095|508|入库删除|||4|business:inbound:remove|
|
||||||
|
1096|508|入库确认|||5|business:inbound:confirm|
|
||||||
|
1097|509|装填查询|||1|business:filling:query|
|
||||||
|
1098|509|装填新增|||2|business:filling:add|
|
||||||
|
1099|510|发货查询|||1|business:shipment:query|
|
||||||
|
1100|510|发货新增|||2|business:shipment:add|
|
||||||
|
1101|510|发货修改|||3|business:shipment:edit|
|
||||||
|
1102|510|发货删除|||4|business:shipment:remove|
|
||||||
|
1103|510|发货确认|||5|business:shipment:confirm|
|
||||||
|
1104|511|使用查询|||1|business:usage:query|
|
||||||
|
1105|511|使用新增|||2|business:usage:add|
|
||||||
|
1106|512|退回查询|||1|business:return:query|
|
||||||
|
1107|512|退回申请|||2|business:return:request|
|
||||||
|
1108|512|退回确认|||3|business:return:confirm|
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_business_menu_fields() -> dict[int, tuple]:
|
||||||
|
expected = {}
|
||||||
|
for spec in BUSINESS_MENU_SPECS.splitlines():
|
||||||
|
menu_id, parent_id, menu_name, path, route_name, order_num, perms, component = spec.split('|')
|
||||||
|
expected[int(menu_id)] = (
|
||||||
|
int(parent_id),
|
||||||
|
menu_name,
|
||||||
|
path,
|
||||||
|
route_name,
|
||||||
|
int(order_num),
|
||||||
|
perms,
|
||||||
|
None if component == '<NULL>' else component,
|
||||||
|
)
|
||||||
|
return expected
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_BUSINESS_MENU_FIELDS = _expected_business_menu_fields()
|
||||||
BUSINESS_MENU_IDS = set(range(121, 124)) | set(range(502, 519)) | set(range(1065, 1109))
|
BUSINESS_MENU_IDS = set(range(121, 124)) | set(range(502, 519)) | set(range(1065, 1109))
|
||||||
MYSQL_MENU_STABLE_COLUMNS = (
|
MYSQL_MENU_STABLE_COLUMNS = (
|
||||||
'menu_name',
|
'menu_name',
|
||||||
@@ -683,6 +840,38 @@ def _business_menu_rows(sql: str) -> dict[int, list]:
|
|||||||
return {int(row[0]): row for row in selected}
|
return {int(row[0]): row for row in selected}
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_business_menu_fields(test_case: unittest.TestCase, sql: str) -> None:
|
||||||
|
rows = _business_menu_rows(sql)
|
||||||
|
invalid_widths = {menu_id: len(row) for menu_id, row in rows.items() if len(row) != SYS_MENU_VALUE_COUNT}
|
||||||
|
test_case.assertEqual({}, invalid_widths)
|
||||||
|
actual = {
|
||||||
|
menu_id: (
|
||||||
|
int(row[2]),
|
||||||
|
row[1],
|
||||||
|
row[4],
|
||||||
|
row[7],
|
||||||
|
int(row[3]),
|
||||||
|
row[13],
|
||||||
|
row[5],
|
||||||
|
)
|
||||||
|
for menu_id, row in rows.items()
|
||||||
|
}
|
||||||
|
test_case.assertEqual(EXPECTED_BUSINESS_MENU_FIELDS, actual)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_business_permission_inventory(test_case: unittest.TestCase, sql: str) -> None:
|
||||||
|
permission_rows = [
|
||||||
|
row
|
||||||
|
for row in _insert_rows(sql, 'sys_menu')
|
||||||
|
if len(row) > SYS_MENU_PERMS_INDEX
|
||||||
|
and row[SYS_MENU_PERMS_INDEX]
|
||||||
|
and re.match(r'^(?:master|business|report):', row[SYS_MENU_PERMS_INDEX])
|
||||||
|
]
|
||||||
|
test_case.assertTrue(all(len(row) == SYS_MENU_VALUE_COUNT for row in permission_rows))
|
||||||
|
actual_permissions = Counter(row[SYS_MENU_PERMS_INDEX] for row in permission_rows)
|
||||||
|
test_case.assertEqual(Counter(EXPECTED_PERMISSIONS), actual_permissions)
|
||||||
|
|
||||||
|
|
||||||
class SqlContractTest(unittest.TestCase):
|
class SqlContractTest(unittest.TestCase):
|
||||||
def test_initializers_define_exact_business_table_set(self) -> None:
|
def test_initializers_define_exact_business_table_set(self) -> None:
|
||||||
for filename in INITIALIZERS:
|
for filename in INITIALIZERS:
|
||||||
@@ -731,13 +920,17 @@ class SqlContractTest(unittest.TestCase):
|
|||||||
self.assertFalse(_is_postgres_naive_timestamp_with_default_precision('occurred_at timestamp with time zone'))
|
self.assertFalse(_is_postgres_naive_timestamp_with_default_precision('occurred_at timestamp with time zone'))
|
||||||
|
|
||||||
def test_initializers_define_fixed_business_menus_and_permissions(self) -> None:
|
def test_initializers_define_fixed_business_menus_and_permissions(self) -> None:
|
||||||
expected_permissions = {page[-1] for page in EXPECTED_PAGES.values()} | {
|
self.assertEqual(17, len(EXPECTED_PAGE_PERMISSIONS))
|
||||||
button[-1] for button in EXPECTED_BUTTONS.values()
|
self.assertEqual(44, len(EXPECTED_BUTTON_PERMISSIONS))
|
||||||
}
|
self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS)
|
||||||
self.assertEqual(61, len(expected_permissions))
|
self.assertEqual(61, len(EXPECTED_PERMISSIONS))
|
||||||
|
self.assertEqual(64, len(EXPECTED_BUSINESS_MENU_FIELDS))
|
||||||
for filename in INITIALIZERS:
|
for filename in INITIALIZERS:
|
||||||
with self.subTest(filename=filename):
|
with self.subTest(filename=filename):
|
||||||
rows = _business_menu_rows((SQL_ROOT / filename).read_text(encoding='utf-8'))
|
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
|
||||||
|
_assert_business_menu_fields(self, sql)
|
||||||
|
_assert_business_permission_inventory(self, sql)
|
||||||
|
rows = _business_menu_rows(sql)
|
||||||
self.assertTrue(all(len(row) == SYS_MENU_VALUE_COUNT for row in rows.values()))
|
self.assertTrue(all(len(row) == SYS_MENU_VALUE_COUNT for row in rows.values()))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
('基础资料维护', '1', '10', 'businessMaster', 'M', ''),
|
('基础资料维护', '1', '10', 'businessMaster', 'M', ''),
|
||||||
@@ -770,15 +963,32 @@ class SqlContractTest(unittest.TestCase):
|
|||||||
('', '', '', '', '1', '0', 'F', '0', '0'),
|
('', '', '', '', '1', '0', 'F', '0', '0'),
|
||||||
tuple(row[index] for index in (4, 5, 6, 7, 8, 9, 10, 11, 12)),
|
tuple(row[index] for index in (4, 5, 6, 7, 8, 9, 10, 11, 12)),
|
||||||
)
|
)
|
||||||
all_menu_rows = _insert_rows((SQL_ROOT / filename).read_text(encoding='utf-8'), 'sys_menu')
|
|
||||||
actual_permissions = {
|
def test_business_permission_inventory_rejects_duplicate_permission(self) -> None:
|
||||||
row[13]
|
sql = (SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8')
|
||||||
for row in all_menu_rows
|
sql += """
|
||||||
if len(row) == SYS_MENU_VALUE_COUNT
|
insert into sys_menu values(
|
||||||
and row[13]
|
'1999', '重复权限', '1', '1', '', '', '', '', 1, 0,
|
||||||
and re.match(r'^(?:master|business|report):', row[13])
|
'F', '0', '0', 'master:strain:list', '#', 'admin', sysdate(), '', null, ''
|
||||||
}
|
);
|
||||||
self.assertEqual(expected_permissions, actual_permissions)
|
"""
|
||||||
|
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
_assert_business_permission_inventory(self, sql)
|
||||||
|
|
||||||
|
def test_business_menu_contract_rejects_same_field_drift_in_both_initializers(self) -> None:
|
||||||
|
for filename in INITIALIZERS:
|
||||||
|
with self.subTest(filename=filename):
|
||||||
|
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
|
||||||
|
mutated, replacements = re.subn(
|
||||||
|
r"(\(?\s*'?502'?\s*,\s*)'菌种资料'",
|
||||||
|
r"\1'漂移菌种资料'",
|
||||||
|
sql,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(1, replacements)
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
_assert_business_menu_fields(self, mutated)
|
||||||
|
|
||||||
def test_mysql_and_postgresql_business_menu_fields_are_identical(self) -> None:
|
def test_mysql_and_postgresql_business_menu_fields_are_identical(self) -> None:
|
||||||
mysql_rows = _business_menu_rows((SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8'))
|
mysql_rows = _business_menu_rows((SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8'))
|
||||||
@@ -914,6 +1124,8 @@ class SqlContractTest(unittest.TestCase):
|
|||||||
self.assertEqual(EXPECTED_UNIQUE_KEYS[table], unique_keys)
|
self.assertEqual(EXPECTED_UNIQUE_KEYS[table], unique_keys)
|
||||||
self.assertEqual(EXPECTED_FOREIGN_KEYS[table], foreign_keys)
|
self.assertEqual(EXPECTED_FOREIGN_KEYS[table], foreign_keys)
|
||||||
self.assertEqual(EXPECTED_CHECKS[table], checks)
|
self.assertEqual(EXPECTED_CHECKS[table], checks)
|
||||||
|
_assert_business_menu_fields(self, raw)
|
||||||
|
_assert_business_permission_inventory(self, raw)
|
||||||
patch_rows = _business_menu_rows(raw)
|
patch_rows = _business_menu_rows(raw)
|
||||||
mysql_rows = _business_menu_rows((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8'))
|
mysql_rows = _business_menu_rows((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8'))
|
||||||
compared_indexes = tuple(range(15))
|
compared_indexes = tuple(range(15))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from 'node:assert/strict'
|
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 { test } from 'node:test'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
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 = { '(': ')', '[': ']', '{': '}' }
|
const delimiterPairs = { '(': ')', '[': ']', '{': '}' }
|
||||||
|
|
||||||
function sanitizeSource(source, { allowTemplateInterpolation = false } = {}) {
|
function sanitizeSource(source, { allowTemplateInterpolation = false } = {}) {
|
||||||
@@ -428,6 +517,99 @@ function splitTopLevelRanges(structure, separator) {
|
|||||||
return ranges
|
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) {
|
function normalizeUrlExpression(code, structure) {
|
||||||
let normalized = ''
|
let normalized = ''
|
||||||
for (let index = 0; index < code.length; index += 1) {
|
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 换序及空白格式变化', () => {
|
test('契约解析器支持 url 与 method 换序及空白格式变化', () => {
|
||||||
const source = `
|
const source = `
|
||||||
export function sample(query) {
|
export function sample(query) {
|
||||||
@@ -771,9 +1052,7 @@ test('六个只读报表页面逐页满足 API、权限、筛选、列和异步
|
|||||||
assert.doesNotMatch(source, /@\/api\/(?:master|business)\//)
|
assert.doesNotMatch(source, /@\/api\/(?:master|business)\//)
|
||||||
assert.match(source, new RegExp(`${contract.apiName}\\(listContext\\.query\\)`))
|
assert.match(source, new RegExp(`${contract.apiName}\\(listContext\\.query\\)`))
|
||||||
|
|
||||||
const actualPermissions = [...new Set(
|
const actualPermissions = extractGuardedVuePermissions(source)
|
||||||
[...source.matchAll(/['"](report:[a-z]+:[a-z]+)['"]/g)].map((match) => match[1])
|
|
||||||
)]
|
|
||||||
assert.deepEqual(actualPermissions, [contract.permission], `${resource} 页面只能出现自己的 list 权限`)
|
assert.deepEqual(actualPermissions, [contract.permission], `${resource} 页面只能出现自己的 list 权限`)
|
||||||
assertButtonsUsePermission(source, 'handleQuery', contract.permission)
|
assertButtonsUsePermission(source, 'handleQuery', contract.permission)
|
||||||
assertButtonsUsePermission(source, 'resetQuery', contract.permission)
|
assertButtonsUsePermission(source, 'resetQuery', contract.permission)
|
||||||
@@ -885,10 +1164,9 @@ test('六个基础资料页面逐页满足 API、权限、分页和安全删除
|
|||||||
assert.deepEqual(importedModules, [resource], `${resource} 页面不得跨调其他主数据 API`)
|
assert.deepEqual(importedModules, [resource], `${resource} 页面不得跨调其他主数据 API`)
|
||||||
assert.deepEqual(extractImportedNames(source, `@/api/master/${resource}`), contract.apiNames)
|
assert.deepEqual(extractImportedNames(source, `@/api/master/${resource}`), contract.apiNames)
|
||||||
|
|
||||||
const actualPermissions = [...new Set(
|
const actualPermissions = extractGuardedVuePermissions(source)
|
||||||
[...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))]
|
.filter((permission) => permission.startsWith(`${permissionPrefix}:`))
|
||||||
.map((match) => match[1].slice(permissionPrefix.length + 1))
|
.map((permission) => permission.slice(permissionPrefix.length + 1))
|
||||||
)].sort()
|
|
||||||
assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`)
|
assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`)
|
||||||
|
|
||||||
assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`)
|
assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`)
|
||||||
@@ -1058,10 +1336,9 @@ test('五个业务页面逐页满足 API、精确权限、列表和交互基础
|
|||||||
assert.doesNotMatch(source, /BarcodeDetector|getUserMedia|camera/i)
|
assert.doesNotMatch(source, /BarcodeDetector|getUserMedia|camera/i)
|
||||||
assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`)
|
assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`)
|
||||||
|
|
||||||
const actualPermissions = [...new Set(
|
const actualPermissions = extractGuardedVuePermissions(source)
|
||||||
[...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))]
|
.filter((permission) => permission.startsWith(`${permissionPrefix}:`))
|
||||||
.map((match) => match[1].slice(permissionPrefix.length + 1))
|
.map((permission) => permission.slice(permissionPrefix.length + 1))
|
||||||
)].sort()
|
|
||||||
assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`)
|
assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`)
|
||||||
assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`)
|
assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`)
|
||||||
assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`)
|
assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`)
|
||||||
|
|||||||
Reference in New Issue
Block a user