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 5489342..1590218 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 @@ -1,6 +1,7 @@ import ast import re import unittest +from collections import Counter from pathlib import Path from fastapi.routing import APIRoute @@ -9,6 +10,7 @@ from common.aspect.interface_auth import CheckUserInterfaceAuth from server import create_app BACKEND_ROOT = Path(__file__).resolve().parents[2] +FRONTEND_ROOT = BACKEND_ROOT.parent / 'shierfeng-fastapi-frontend' CONTROLLER_NAMES = ( 'strain', 'customer', @@ -31,28 +33,96 @@ TARGET_PREFIXES = ( ROUTE_HTTP_METHODS = frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE'}) WRITE_METHODS = frozenset({'DELETE', 'PATCH', 'POST', 'PUT'}) -EXPECTED_PERMISSION_ACTIONS = { - 'master:strain': ('list', 'query', 'add', 'edit', 'remove'), - 'master:customer': ('list', 'query', 'add', 'edit', 'remove'), - 'master:site': ('list', 'query', 'add', 'edit', 'remove', 'assign'), - 'master:line': ('list', 'query', 'add', 'edit', 'remove'), - 'master:warehouse': ('list', 'query', 'add', 'edit', 'remove', 'assign'), - 'master:pallet': ('list', 'query', 'add', 'edit', 'remove', 'print'), - 'business:inbound': ('list', 'query', 'add', 'edit', 'remove', 'confirm'), - 'business:filling': ('list', 'query', 'add'), - 'business:shipment': ('list', 'query', 'add', 'edit', 'remove', 'confirm'), - 'business:usage': ('list', 'query', 'add'), - 'business:return': ('list', 'query', 'request', 'confirm'), - 'report:palletinventory': ('list',), - 'report:filling': ('list',), - 'report:shipment': ('list',), - 'report:consumption': ('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_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 +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]: @@ -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 + 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\[[^\[\]]*\])\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[^"]*)"|'(?P[^']*)')""") + 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[^"]*)"|'(?P[^']*)')""") + 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): @classmethod def setUpClass(cls) -> None: @@ -246,13 +429,76 @@ class ApiPermissionContractTest(unittest.TestCase): literals.append(node.args[0].value) 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(EXPECTED_PERMISSIONS, frozenset(literals)) + _assert_controller_permission_literals(self, literals) self.assertEqual( {'add', 'assign', 'confirm', 'edit', 'list', 'print', 'query', 'remove', 'request'}, {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 = """ + + + """ + + 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 = ( + '', + '', + '', + ) + for source in dynamic_sources: + with self.subTest(source=source), self.assertRaises(AssertionError): + _extract_guarded_vue_permissions(source) + + guarded = _extract_guarded_vue_permissions( + '' + ) + 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: route_order = { route.path: index diff --git a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py index d094249..01514a5 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py @@ -1,11 +1,13 @@ import re import unittest +from collections import Counter from pathlib import Path SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql' INITIALIZERS = ('shierfeng-fastapi.sql', 'shierfeng-fastapi-pg.sql') PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql' SYS_MENU_VALUE_COUNT = 20 +SYS_MENU_PERMS_INDEX = 13 REMOVED_AI_DICT_CODES = set(range(33, 70)) EXPECTED_TABLES = { @@ -408,6 +410,161 @@ EXPECTED_BUTTONS = { 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|| +122|1|业务管理|businessOperation||11|| +123|1|报表查询|businessReport||12|| +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 == '' 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)) MYSQL_MENU_STABLE_COLUMNS = ( 'menu_name', @@ -683,6 +840,38 @@ def _business_menu_rows(sql: str) -> dict[int, list]: 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): def test_initializers_define_exact_business_table_set(self) -> None: 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')) def test_initializers_define_fixed_business_menus_and_permissions(self) -> None: - expected_permissions = {page[-1] for page in EXPECTED_PAGES.values()} | { - button[-1] for button in EXPECTED_BUTTONS.values() - } - self.assertEqual(61, len(expected_permissions)) + 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(64, len(EXPECTED_BUSINESS_MENU_FIELDS)) for filename in INITIALIZERS: 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.assertEqual( ('基础资料维护', '1', '10', 'businessMaster', 'M', ''), @@ -770,15 +963,32 @@ class SqlContractTest(unittest.TestCase): ('', '', '', '', '1', '0', 'F', '0', '0'), 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 = { - row[13] - for row in all_menu_rows - if len(row) == SYS_MENU_VALUE_COUNT - and row[13] - and re.match(r'^(?:master|business|report):', row[13]) - } - self.assertEqual(expected_permissions, actual_permissions) + + def test_business_permission_inventory_rejects_duplicate_permission(self) -> None: + sql = (SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8') + sql += """ + insert into sys_menu values( + '1999', '重复权限', '1', '1', '', '', '', '', 1, 0, + 'F', '0', '0', 'master:strain:list', '#', 'admin', sysdate(), '', null, '' + ); + """ + + 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: 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_FOREIGN_KEYS[table], foreign_keys) self.assertEqual(EXPECTED_CHECKS[table], checks) + _assert_business_menu_fields(self, raw) + _assert_business_permission_inventory(self, raw) patch_rows = _business_menu_rows(raw) mysql_rows = _business_menu_rows((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8')) compared_indexes = tuple(range(15)) diff --git a/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs b/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs index 1942874..e74eb19 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 { readFile } from 'node:fs/promises' +import { lstat, readFile } from 'node:fs/promises' import { test } from 'node:test' import { fileURLToPath } from 'node:url' 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 = { '(': ')', '[': ']', '{': '}' } function sanitizeSource(source, { allowTemplateInterpolation = false } = {}) { @@ -428,6 +517,99 @@ function splitTopLevelRanges(structure, separator) { 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(//g, (comment) => comment.replace(/[^\r\n]/g, ' ')) + assert.doesNotMatch(withoutComments, / + + ` + assert.deepEqual(extractGuardedVuePermissions(staleSource), []) + assert.deepEqual( + extractGuardedVuePermissions( + `` + ), + [] + ) + assert.throws( + () => extractGuardedVuePermissions(''), + /静态字符串数组/ + ) + assert.throws( + () => extractGuardedVuePermissions(''), + /静态字符串数组/ + ) + assert.throws( + () => extractGuardedVuePermissions(``), + /动态表达式/ + ) + + 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 换序及空白格式变化', () => { const source = ` export function sample(query) { @@ -771,9 +1052,7 @@ test('六个只读报表页面逐页满足 API、权限、筛选、列和异步 assert.doesNotMatch(source, /@\/api\/(?:master|business)\//) assert.match(source, new RegExp(`${contract.apiName}\\(listContext\\.query\\)`)) - const actualPermissions = [...new Set( - [...source.matchAll(/['"](report:[a-z]+:[a-z]+)['"]/g)].map((match) => match[1]) - )] + const actualPermissions = extractGuardedVuePermissions(source) assert.deepEqual(actualPermissions, [contract.permission], `${resource} 页面只能出现自己的 list 权限`) assertButtonsUsePermission(source, 'handleQuery', contract.permission) assertButtonsUsePermission(source, 'resetQuery', contract.permission) @@ -885,10 +1164,9 @@ test('六个基础资料页面逐页满足 API、权限、分页和安全删除 assert.deepEqual(importedModules, [resource], `${resource} 页面不得跨调其他主数据 API`) assert.deepEqual(extractImportedNames(source, `@/api/master/${resource}`), contract.apiNames) - const actualPermissions = [...new Set( - [...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))] - .map((match) => match[1].slice(permissionPrefix.length + 1)) - )].sort() + const actualPermissions = extractGuardedVuePermissions(source) + .filter((permission) => permission.startsWith(`${permissionPrefix}:`)) + .map((permission) => permission.slice(permissionPrefix.length + 1)) assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`) assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`) @@ -1058,10 +1336,9 @@ test('五个业务页面逐页满足 API、精确权限、列表和交互基础 assert.doesNotMatch(source, /BarcodeDetector|getUserMedia|camera/i) assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`) - const actualPermissions = [...new Set( - [...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))] - .map((match) => match[1].slice(permissionPrefix.length + 1)) - )].sort() + const actualPermissions = extractGuardedVuePermissions(source) + .filter((permission) => permission.startsWith(`${permissionPrefix}:`)) + .map((permission) => permission.slice(permissionPrefix.length + 1)) assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`) assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`) assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`)