测试:校验菜单接口权限一致性

This commit is contained in:
焦龙言
2026-07-27 14:59:36 +08:00
parent 9b5f479f0d
commit 33d73ca0ef
3 changed files with 783 additions and 48 deletions
@@ -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`)